cssgrep 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.1.0] - 2026-07-02
11
+
12
+ ### Added
13
+ - `-H`/`--with-filename` and `--no-filename` to force the `file:` prefix on or off.
14
+ - `-s`/`--no-messages` to suppress errors for unreadable or missing files.
15
+ - `--include`/`--exclude` file globs with brace alternation (`{a,b,c}`).
16
+ `--exclude` is an alias of `--ignore`; `--include` replaces the `--ext` filter.
17
+ - `--max-depth <n>` to cap `-r` recursion depth.
18
+ - Automatic skipping of binary files (NUL byte / control-byte heuristic), with a
19
+ note on stderr suppressible by `-s`/`-q`.
20
+
21
+ ### Changed
22
+ - CI now runs on Linux, macOS and Windows across Node 20 and 22.
23
+ - Releases publish to npm with provenance and smoke-test the built binary.
24
+
25
+ ## [1.0.0] - 2026-06-29
26
+
27
+ ### Added
28
+ - Initial release: match a CSS selector against HTML and print each hit
29
+ grep-style, with byte-accurate `line:col` via `-n` (works on minified HTML).
30
+ - Recursive search: `-r`/`--recursive`, `--ext`, `-i`/`--ignore`, `--ignore-file`.
31
+ - Print modes: default line, `-p`/`--print`, `--attr`, `--text`, `--json`.
32
+ - `--parent <n>` structural context; line context `-A`/`-B`/`-C`.
33
+ - Limits `-m`/`--max-count`, `-M`/`--max-total`; aggregates `-c`, `-l`/`-L`, `-q`.
34
+ - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
35
+ - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
36
+
37
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...HEAD
38
+ [1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
39
+ [1.0.0]: https://github.com/msbatarce/cssgrep/releases/tag/v1.0.0
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # cssgrep
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/cssgrep.svg)](https://www.npmjs.com/package/cssgrep)
4
+ [![test](https://github.com/msbatarce/cssgrep/actions/workflows/test.yml/badge.svg)](https://github.com/msbatarce/cssgrep/actions/workflows/test.yml)
5
+ [![license](https://img.shields.io/npm/l/cssgrep.svg)](LICENSE)
6
+
3
7
  Search HTML by CSS selector and print each match, grep-style — add `-n` to
4
8
  report it as `file:line:col`.
5
9
 
@@ -73,8 +77,11 @@ locator appears only with `-n`:
73
77
  | Flag | Description |
74
78
  |------|-------------|
75
79
  | `-r`, `--recursive` | Recurse into directory arguments (defaults to `.` if none given). |
80
+ | `--max-depth <n>` | Limit `-r` recursion depth (`1` = the given directory only, no subdirectories). |
76
81
  | `--ext <list>` | Extensions to scan with `-r` (default `html,htm`). Value attaches with `=`: `--ext htm` or `--ext=htm`. |
82
+ | `--include <glob>` | Only search files whose name/path matches `<glob>` while recursing (repeatable). Replaces `--ext` — the two can't be combined. e.g. `--include '*.{html,htm,xhtml}'`. |
77
83
  | `-i`, `--ignore <glob>` | Skip files/dirs matching `<glob>` while recursing (repeatable). `node_modules`, `*.min.html`, `build/` (dir-only), or path globs like `src/vendor/**`. |
84
+ | `--exclude <glob>` | Alias for `--ignore` (grep's name for the same thing). |
78
85
  | `--ignore-file <path>` | Load ignore globs from a file, one per line (`#` comments and blank lines ignored) — like a `.gitignore`. |
79
86
  | `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
80
87
  | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
@@ -92,7 +99,10 @@ locator appears only with `-n`:
92
99
  | `-l`, `--files-with-matches` | Print only the names of files that contain a match. |
93
100
  | `-L`, `--files-without-match` | Print only the names of files with no match. |
94
101
  | `-q`, `--quiet` | Print nothing; exit `0` on the first match, `1` if none. Stops early. |
102
+ | `-s`, `--no-messages` | Suppress error messages for unreadable or missing files (handy with `-r`). |
95
103
  | `-0`, `--null` | Output a NUL after each file name instead of `:` (or, with `-l`/`-L`, instead of the newline) — pipe to `xargs -0`. |
104
+ | `-H`, `--with-filename` | Always print the `file:` prefix, even for a single file or stdin. |
105
+ | `--no-filename` | Never print the `file:` prefix, even when searching multiple files. |
96
106
  | `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `always`. |
97
107
  | `-h`, `--help` | Show help. |
98
108
  | `-V`, `--version` | Print the version and exit. |
@@ -100,6 +110,15 @@ locator appears only with `-n`:
100
110
  Boolean short flags can be combined into one token (`-rn` is `-r -n`), and a
101
111
  value-taking flag may close such a cluster (`-rnw100`).
102
112
 
113
+ Globs for `--include`/`--ignore`/`--exclude` support `*` (within a path
114
+ segment), `**` (across `/`), `?` (one non-slash char), and brace alternation
115
+ like `*.{html,htm}`. A trailing `/` matches directories only; a pattern with a
116
+ `/` matches against the path, otherwise the basename — gitignore-flavored.
117
+
118
+ Binary files are detected (a NUL byte or a high ratio of control bytes in the
119
+ first 8 KB) and skipped with a note on stderr — parsing them as HTML is never
120
+ useful. Suppress the note with `-s` (or `-q`).
121
+
103
122
  When coloring is on, the matched node is highlighted within its line (grep's
104
123
  bold-red); the `file:` prefix and `line:col` locator get their own colors
105
124
  (magenta and green, like grep). Plain `-p` prints no color (the whole block is
@@ -6,8 +6,11 @@
6
6
  _cssgrep() {
7
7
  _arguments -s -S \
8
8
  '(-r --recursive)'{-r,--recursive}'[recurse into directory arguments]' \
9
+ '--max-depth[limit -r recursion depth (1 = given dir only)]:depth:' \
9
10
  '--ext[extensions to scan with -r]:extensions (comma-separated):' \
11
+ '*--include[only search files matching glob (replaces --ext)]:glob:' \
10
12
  '*'{-i,--ignore}'[skip files/dirs matching glob while recursing]:glob:' \
13
+ '*--exclude[alias for --ignore]:glob:' \
11
14
  '--ignore-file[read ignore globs from a file]:file:_files' \
12
15
  '(-n --line-number)'{-n,--line-number}'[prefix each match with line:col]' \
13
16
  '(-p --print)'{-p,--print}'[pretty-print the matched node HTML]' \
@@ -25,7 +28,10 @@ _cssgrep() {
25
28
  '(-l --files-with-matches)'{-l,--files-with-matches}'[print only names of files with a match]' \
26
29
  '(-L --files-without-match)'{-L,--files-without-match}'[print only names of files without a match]' \
27
30
  '(-q --quiet)'{-q,--quiet}'[print nothing; exit on first match]' \
31
+ '(-s --no-messages)'{-s,--no-messages}'[suppress errors for unreadable/missing files]' \
28
32
  '(-0 --null)'{-0,--null}'[separate the file name with a NUL byte]' \
33
+ '(--no-filename)'{-H,--with-filename}'[always print the file name prefix]' \
34
+ '(-H --with-filename)--no-filename[never print the file name prefix]' \
29
35
  '--color=-[colorize output]::when:(auto always never)' \
30
36
  '(-h --help)'{-h,--help}'[show help and exit]' \
31
37
  '(-V --version)'{-V,--version}'[show version and exit]' \
@@ -6,11 +6,12 @@ _cssgrep() {
6
6
  cur="${COMP_WORDS[COMP_CWORD]}"
7
7
  prev="${COMP_WORDS[COMP_CWORD-1]}"
8
8
 
9
- opts="-r --recursive --ext -i --ignore --ignore-file -n --line-number \
9
+ opts="-r --recursive --max-depth --ext --include -i --ignore --exclude --ignore-file -n --line-number \
10
10
  -p --print --attr --text --json --parent -w --max-width \
11
11
  -A --after-context -B --before-context -C --context \
12
12
  -m --max-count -M --max-total -c --count \
13
- -l --files-with-matches -L --files-without-match -q --quiet -0 --null \
13
+ -l --files-with-matches -L --files-without-match -q --quiet -s --no-messages -0 --null \
14
+ -H --with-filename --no-filename \
14
15
  --color -h --help -V --version"
15
16
 
16
17
  case "$prev" in
@@ -22,7 +23,7 @@ _cssgrep() {
22
23
  COMPREPLY=( $(compgen -f -- "$cur") )
23
24
  return 0
24
25
  ;;
25
- --ext|-i|--ignore|--attr|--parent|-w|--max-width|-A|--after-context|\
26
+ --max-depth|--ext|--include|-i|--ignore|--exclude|--attr|--parent|-w|--max-width|-A|--after-context|\
26
27
  -B|--before-context|-C|--context|-m|--max-count|-M|--max-total)
27
28
  # value-taking flag with no enumerable completion; offer nothing
28
29
  return 0
@@ -2,8 +2,11 @@
2
2
  # Install: place this file in ~/.config/fish/completions/ (named cssgrep.fish).
3
3
 
4
4
  complete -c cssgrep -s r -l recursive -d 'Recurse into directory arguments'
5
+ complete -c cssgrep -l max-depth -x -d 'Limit -r recursion depth (1 = given dir only)'
5
6
  complete -c cssgrep -l ext -x -d 'Extensions to scan with -r (comma-separated)'
7
+ complete -c cssgrep -l include -x -d 'Only search files matching glob (replaces --ext)'
6
8
  complete -c cssgrep -s i -l ignore -x -d 'Skip files/dirs matching glob while recursing'
9
+ complete -c cssgrep -l exclude -x -d 'Alias for --ignore'
7
10
  complete -c cssgrep -l ignore-file -r -F -d 'Read ignore globs from a file'
8
11
  complete -c cssgrep -s n -l line-number -d 'Prefix each match with line:col'
9
12
  complete -c cssgrep -s p -l print -d 'Pretty-print the matched node HTML'
@@ -21,7 +24,10 @@ complete -c cssgrep -s c -l count -d 'Print only a count of matches'
21
24
  complete -c cssgrep -s l -l files-with-matches -d 'Print only names of files with a match'
22
25
  complete -c cssgrep -s L -l files-without-match -d 'Print only names of files without a match'
23
26
  complete -c cssgrep -s q -l quiet -d 'Print nothing; exit on first match'
27
+ complete -c cssgrep -s s -l no-messages -d 'Suppress errors for unreadable/missing files'
24
28
  complete -c cssgrep -s 0 -l null -d 'Separate the file name with a NUL byte'
29
+ complete -c cssgrep -s H -l with-filename -d 'Always print the file name prefix'
30
+ complete -c cssgrep -l no-filename -d 'Never print the file name prefix'
25
31
  complete -c cssgrep -l color -x -a 'auto always never' -d 'Colorize output'
26
32
  complete -c cssgrep -s h -l help -d 'Show help and exit'
27
33
  complete -c cssgrep -s V -l version -d 'Show version and exit'
package/index.js CHANGED
@@ -12,7 +12,7 @@ const { html: beautify } = require('js-beautify');
12
12
  // package.json, so it survives compilation into a standalone binary (Bun
13
13
  // --compile / Node SEA), where package.json won't sit next to the executable.
14
14
  // Keep in sync with package.json on release.
15
- const VERSION = '1.0.0';
15
+ const VERSION = '1.1.0';
16
16
 
17
17
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
18
18
 
@@ -29,8 +29,11 @@ Output (one line per match):
29
29
 
30
30
  Options:
31
31
  -r, --recursive Recurse into directory arguments.
32
+ --max-depth <n> Limit -r recursion depth (1 = the given dir only).
32
33
  --ext <list> Comma-separated extensions for -r (default: html,htm).
34
+ --include <glob> Only search files matching <glob> (replaces --ext; repeatable).
33
35
  -i, --ignore <glob> Skip files/dirs matching <glob> when recursing (repeatable).
36
+ --exclude <glob> Alias for --ignore.
34
37
  --ignore-file <path> Read ignore globs from <path> (one per line, # comments).
35
38
  -n, --line-number Prefix each match with its line:col (excludes -c, -p).
36
39
  -p, --print Pretty-print the matched node's HTML above its location.
@@ -48,7 +51,10 @@ Options:
48
51
  -l, --files-with-matches Print only the names of files that have a match.
49
52
  -L, --files-without-match Print only the names of files with no match.
50
53
  -q, --quiet Print nothing; exit 0 on first match, 1 if none.
54
+ -s, --no-messages Suppress error messages for unreadable/missing files.
51
55
  -0, --null Separate the file name with a NUL byte (for xargs -0).
56
+ -H, --with-filename Always print the file name prefix (even for one file).
57
+ --no-filename Never print the file name prefix (even for many files).
52
58
  --color[=<when>] Colorize output: auto (default), always or never.
53
59
  -h, --help Show this help.
54
60
  -V, --version Show version and exit.
@@ -57,6 +63,9 @@ Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
57
63
  (-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
58
64
  value with = or as the next word (--max-width=100, --ext htm).
59
65
 
66
+ Globs (--include/--ignore/--exclude) support *, ** (crosses /), ?, and brace
67
+ alternation like *.{html,htm}.
68
+
60
69
  Exit status: 0 if any match was found, 1 if none, 2 on error.`;
61
70
 
62
71
  function fail(msg) {
@@ -84,6 +93,8 @@ function parseArgs(argv) {
84
93
  paths: [],
85
94
  recursive: false,
86
95
  exts: ['html', 'htm'],
96
+ extGiven: false,
97
+ maxDepth: 0,
87
98
  lineNumber: false,
88
99
  print: false,
89
100
  maxWidth: 0,
@@ -101,9 +112,14 @@ function parseArgs(argv) {
101
112
  before: 0,
102
113
  after: 0,
103
114
  ignore: [],
115
+ include: [],
116
+ withFilename: false,
117
+ noFilename: false,
118
+ noMessages: false,
104
119
  color: 'auto',
105
120
  };
106
121
  const setExts = v => {
122
+ opts.extGiven = true;
107
123
  opts.exts = (v || '').split(',').map(s => s.trim().replace(/^\./, '')).filter(Boolean);
108
124
  };
109
125
  const boundedInt = (v, what, min) => {
@@ -115,11 +131,13 @@ function parseArgs(argv) {
115
131
  const setMaxWidth = v => { opts.maxWidth = positiveInt(v, '--max-width'); };
116
132
  const setMaxCount = v => { opts.maxCount = positiveInt(v, '--max-count'); };
117
133
  const setMaxTotal = v => { opts.maxTotal = positiveInt(v, '--max-total'); };
134
+ const setMaxDepth = v => { opts.maxDepth = positiveInt(v, '--max-depth'); };
118
135
  const setParent = v => { opts.parent = positiveInt(v, '--parent'); };
119
136
  const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
120
137
  const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
121
138
  const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
122
139
  const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
140
+ const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
123
141
  const addIgnoreFile = v => {
124
142
  let content;
125
143
  try { content = fs.readFileSync(v, 'utf8'); }
@@ -152,12 +170,17 @@ function parseArgs(argv) {
152
170
  case '--files-without-match': opts.filesWithoutMatch = true; break;
153
171
  case '--quiet': case '--silent': opts.quiet = true; break;
154
172
  case '--null': opts.nul = true; break;
173
+ case '--with-filename': opts.withFilename = true; break;
174
+ case '--no-filename': opts.noFilename = true; break;
175
+ case '--no-messages': opts.noMessages = true; break;
155
176
  case '--ext': setExts(value()); break;
156
- case '--ignore': addIgnore(value()); break;
177
+ case '--ignore': case '--exclude': addIgnore(value()); break;
157
178
  case '--ignore-file': addIgnoreFile(value()); break;
179
+ case '--include': addInclude(value()); break;
158
180
  case '--max-width': setMaxWidth(value()); break;
159
181
  case '--max-count': setMaxCount(value()); break;
160
182
  case '--max-total': setMaxTotal(value()); break;
183
+ case '--max-depth': setMaxDepth(value()); break;
161
184
  case '--attr': opts.attr = value(); break;
162
185
  case '--text': opts.text = true; break;
163
186
  case '--json': opts.json = true; break;
@@ -193,6 +216,8 @@ function parseArgs(argv) {
193
216
  case 'L': opts.filesWithoutMatch = true; break;
194
217
  case 'q': opts.quiet = true; break;
195
218
  case '0': case 'Z': opts.nul = true; break;
219
+ case 'H': opts.withFilename = true; break;
220
+ case 's': opts.noMessages = true; break;
196
221
  default: fail(`unknown option: -${ch}`);
197
222
  }
198
223
  }
@@ -206,6 +231,10 @@ function parseArgs(argv) {
206
231
  const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
207
232
  .filter(Boolean).length;
208
233
  if (aggregates > 1) fail('only one of -c, -l, -L, -q may be given');
234
+ // Filename prefix can be forced on or off, but not both (grep -H / -h).
235
+ if (opts.withFilename && opts.noFilename) fail('-H cannot be combined with --no-filename');
236
+ // --include fully replaces the extension filter, so the two can't coexist.
237
+ if (opts.extGiven && opts.include.length) fail('--ext and --include cannot be combined');
209
238
  // Per-match print modes choose what is printed for each match; only one.
210
239
  const printModes = [opts.print, opts.attr != null, opts.text, opts.json].filter(Boolean).length;
211
240
  if (printModes > 1) fail('only one of -p, --attr, --text, --json may be given');
@@ -594,9 +623,11 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
594
623
  }
595
624
 
596
625
  // Translate a glob to a regex body. `*` matches within a path segment, `**`
597
- // across segments, `?` a single non-slash char; everything else is literal.
626
+ // across segments, `?` a single non-slash char, `{a,b,c}` alternation; every
627
+ // other char is literal.
598
628
  function globToRegex(glob) {
599
629
  let re = '';
630
+ let depth = 0; // open `{` alternation groups
600
631
  for (let i = 0; i < glob.length; i++) {
601
632
  const c = glob[i];
602
633
  if (c === '*') {
@@ -609,12 +640,21 @@ function globToRegex(glob) {
609
640
  }
610
641
  } else if (c === '?') {
611
642
  re += '[^/]';
612
- } else if (/[.+^${}()|[\]\\]/.test(c)) {
643
+ } else if (c === '{') {
644
+ re += '(';
645
+ depth++;
646
+ } else if (c === '}' && depth > 0) {
647
+ re += ')';
648
+ depth--;
649
+ } else if (c === ',' && depth > 0) {
650
+ re += '|';
651
+ } else if (/[.+^$()|[\]\\]/.test(c)) { // note: { } handled above
613
652
  re += '\\' + c;
614
653
  } else {
615
654
  re += c;
616
655
  }
617
656
  }
657
+ while (depth-- > 0) re += ')'; // close any unbalanced `{`
618
658
  return re;
619
659
  }
620
660
 
@@ -631,7 +671,9 @@ function compileIgnore(pattern) {
631
671
  return { re, dirOnly, hasSlash };
632
672
  }
633
673
 
634
- function isIgnored(name, full, isDir, matchers) {
674
+ // True if name/path matches any compiled glob matcher (shared by --ignore/
675
+ // --exclude and --include).
676
+ function matchesAny(name, full, isDir, matchers) {
635
677
  for (const m of matchers) {
636
678
  if (m.dirOnly && !isDir) continue;
637
679
  if (m.re.test(m.hasSlash ? full : name)) return true;
@@ -639,23 +681,29 @@ function isIgnored(name, full, isDir, matchers) {
639
681
  return false;
640
682
  }
641
683
 
642
- function* walk(dir, opts) {
684
+ function* walk(dir, opts, depth = 1) {
643
685
  let entries;
644
686
  try {
645
687
  entries = fs.readdirSync(dir, { withFileTypes: true });
646
688
  } catch (e) {
647
- process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
689
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
648
690
  return;
649
691
  }
650
692
  for (const e of entries) {
651
693
  const full = path.join(dir, e.name);
652
694
  const isDir = e.isDirectory();
653
- if (opts.ignore.length && isIgnored(e.name, full, isDir, opts.ignore)) continue;
695
+ if (opts.ignore.length && matchesAny(e.name, full, isDir, opts.ignore)) continue;
654
696
  if (isDir) {
655
- yield* walk(full, opts);
697
+ // --max-depth caps how far we descend; depth 1 = the target's children.
698
+ if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1);
656
699
  } else if (e.isFile()) {
657
- const ext = path.extname(e.name).slice(1).toLowerCase();
658
- if (opts.exts.includes(ext)) yield full;
700
+ // --include replaces the extension filter; otherwise filter by --ext.
701
+ if (opts.include.length) {
702
+ if (matchesAny(e.name, full, false, opts.include)) yield full;
703
+ } else {
704
+ const ext = path.extname(e.name).slice(1).toLowerCase();
705
+ if (opts.exts.includes(ext)) yield full;
706
+ }
659
707
  }
660
708
  }
661
709
  }
@@ -664,6 +712,23 @@ function readStdin() {
664
712
  return fs.readFileSync(0, 'utf8');
665
713
  }
666
714
 
715
+ // Heuristic binary-file detector (grep/git style). A NUL byte in the first 8 KB
716
+ // is a decisive binary signal; failing that, a high ratio of non-text control
717
+ // bytes flags binary content that happens to lack NULs. HTML is text, so binary
718
+ // files are never worth parsing and are skipped. (stat() can't tell us this —
719
+ // only the bytes can.)
720
+ const BINARY_SNIFF_BYTES = 8192;
721
+ function looksBinary(buf) {
722
+ const n = Math.min(buf.length, BINARY_SNIFF_BYTES);
723
+ let suspicious = 0;
724
+ for (let i = 0; i < n; i++) {
725
+ const b = buf[i];
726
+ if (b === 0) return true; // NUL: definitely binary
727
+ if (b < 9 || (b > 13 && b < 32)) suspicious++; // control char (not \t\n\v\f\r)
728
+ }
729
+ return n > 0 && suspicious / n > 0.3;
730
+ }
731
+
667
732
  function main() {
668
733
  const opts = parseArgs(process.argv.slice(2));
669
734
  resolveSelectorAndPaths(opts);
@@ -676,7 +741,7 @@ function main() {
676
741
  const targets = opts.paths.length ? opts.paths : ['.'];
677
742
  for (const p of targets) {
678
743
  const st = fs.statSync(p, { throwIfNoEntry: false });
679
- if (!st) { process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
744
+ if (!st) { if (!opts.noMessages) process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
680
745
  if (st.isDirectory()) files.push(...walk(p, opts));
681
746
  else files.push(p);
682
747
  }
@@ -686,8 +751,11 @@ function main() {
686
751
  useStdin = true;
687
752
  }
688
753
 
689
- // A label (file prefix) is shown when searching more than one file.
690
- const showLabel = !useStdin && files.length > 1;
754
+ // A label (file prefix) is shown when searching more than one file; -H forces
755
+ // it on (even for one file or stdin) and --no-filename forces it off.
756
+ const showLabel = opts.withFilename ? true
757
+ : opts.noFilename ? false
758
+ : (!useStdin && files.length > 1);
691
759
 
692
760
  const out = [];
693
761
  let total = 0;
@@ -697,18 +765,24 @@ function main() {
697
765
 
698
766
  try {
699
767
  if (useStdin) {
700
- total += searchSource(readStdin(), '(standard input)', false, opts, out, room());
768
+ total += searchSource(readStdin(), '(standard input)', showLabel, opts, out, room());
701
769
  } else {
702
770
  for (const f of files) {
703
771
  if (room() <= 0) break; // -M: global budget exhausted
704
- let src;
772
+ let buf;
705
773
  try {
706
- src = fs.readFileSync(f, 'utf8');
774
+ buf = fs.readFileSync(f); // Buffer: sniff before decoding
707
775
  } catch (e) {
708
- process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
776
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
777
+ continue;
778
+ }
779
+ if (looksBinary(buf)) {
780
+ if (!opts.noMessages && !opts.quiet) {
781
+ process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
782
+ }
709
783
  continue;
710
784
  }
711
- total += searchSource(src, f, showLabel, opts, out, room());
785
+ total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
712
786
  if (opts.quiet && total > 0) break; // -q: first match decides the status
713
787
  }
714
788
  }
package/man/cssgrep.1 CHANGED
@@ -1,6 +1,6 @@
1
1
  .\" Man page for cssgrep. Keep the OPTIONS section in sync with the USAGE
2
2
  .\" string in index.js.
3
- .TH CSSGREP 1 "2026-06-29" "cssgrep 1.0.0" "User Commands"
3
+ .TH CSSGREP 1 "2026-07-02" "cssgrep 1.1.0" "User Commands"
4
4
  .SH NAME
5
5
  cssgrep \- search HTML by CSS selector, grep-style
6
6
  .SH SYNOPSIS
@@ -33,7 +33,13 @@ Input is read from the
33
33
  .I file
34
34
  arguments, recursively from directories with
35
35
  .BR \-r ,
36
- or from standard input when no path is given. One line is printed per match.
36
+ or from standard input when no path is given. Binary files (a NUL byte or a
37
+ high ratio of control bytes in the first 8 KB) are skipped with a note on
38
+ standard error, suppressible with
39
+ .B \-s
40
+ or
41
+ .BR \-q .
42
+ One line is printed per match.
37
43
  As with
38
44
  .BR grep ,
39
45
  the matched line is printed on its own; a
@@ -48,17 +54,35 @@ locator appears only with
48
54
  Recurse into directory arguments (defaults to the current directory if none
49
55
  are given).
50
56
  .TP
57
+ .BI \-\-max\-depth " n"
58
+ Limit
59
+ .B \-r
60
+ recursion depth.
61
+ .I 1
62
+ means the given directory only (no subdirectories).
63
+ .TP
51
64
  .BI \-\-ext " list"
52
65
  Comma-separated extensions to scan with
53
66
  .B \-r
54
67
  (default:
55
68
  .IR html,htm ).
56
69
  .TP
70
+ .BI \-\-include " glob"
71
+ Only search files whose name/path matches
72
+ .I glob
73
+ while recursing (repeatable). Replaces
74
+ .BR \-\-ext ;
75
+ the two cannot be combined.
76
+ .TP
57
77
  .BR \-i ", " \-\-ignore " \fIglob\fR"
58
78
  Skip files/dirs matching
59
79
  .I glob
60
80
  while recursing (repeatable).
61
81
  .TP
82
+ .BI \-\-exclude " glob"
83
+ Alias for
84
+ .BR \-\-ignore .
85
+ .TP
62
86
  .BI \-\-ignore\-file " path"
63
87
  Read ignore globs from
64
88
  .IR path ,
@@ -139,10 +163,19 @@ Print only the names of files with no match.
139
163
  .BR \-q ", " \-\-quiet
140
164
  Print nothing; exit 0 on the first match, 1 if none.
141
165
  .TP
166
+ .BR \-s ", " \-\-no\-messages
167
+ Suppress error messages for unreadable or missing files.
168
+ .TP
142
169
  .BR \-0 ", " \-\-null
143
170
  Separate the file name with a NUL byte (for
144
171
  .BR "xargs \-0" ).
145
172
  .TP
173
+ .BR \-H ", " \-\-with\-filename
174
+ Always print the file name prefix, even for a single file or standard input.
175
+ .TP
176
+ .B \-\-no\-filename
177
+ Never print the file name prefix, even when searching multiple files.
178
+ .TP
146
179
  .BR \-\-color [ =\fIwhen\fR ]
147
180
  Colorize output:
148
181
  .I auto
@@ -169,6 +202,12 @@ Long options take a value with
169
202
  .B =
170
203
  or as the next word
171
204
  .RB ( \-\-max\-width=100 ", " "\-\-ext htm" ).
205
+ .PP
206
+ Globs for
207
+ .BR \-\-include ", " \-\-ignore ", and " \-\-exclude
208
+ support
209
+ .BR * " (within a path segment), " ** " (across " / ),
210
+ .BR ? " (one non-slash char), and brace alternation like " *.{html,htm} .
172
211
  .SH EXIT STATUS
173
212
  .TP
174
213
  .B 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Search HTML by CSS selector and print matches grep-style (file:line:col with -n).",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "index.js",
12
12
  "README.md",
13
+ "CHANGELOG.md",
13
14
  "LICENSE",
14
15
  "man/cssgrep.1",
15
16
  "completions/"