cssgrep 1.0.0 → 1.2.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,70 @@
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.2.0] - 2026-07-02
11
+
12
+ ### Added
13
+ - `-S`/`--follow` to follow symbolic links while recursing with `-r`
14
+ (skipped by default). Cycle-safe: each physical directory is visited once.
15
+
16
+ ### Fixed
17
+ - Large result sets are no longer truncated when stdout is a pipe (the process
18
+ exited before async pipe writes had flushed).
19
+ - Columns (`-n`, `-A`/`-B`/`-C`, `--json`) now count bytes — what vim's
20
+ `grepformat %c` expects — instead of UTF-16 code units, so non-ASCII text
21
+ before a match no longer skews the locator.
22
+ - A value-taking option with no value (e.g. a trailing `--ignore` or `--attr`)
23
+ now fails cleanly with exit 2 instead of crashing or being silently ignored.
24
+ - `**/` in globs stops at path-segment boundaries: `--include '**/foo.html'`
25
+ no longer matches `barfoo.html`.
26
+ - `--attr` matches attribute names case-insensitively (the parser lowercases
27
+ them), and exits 1 when every match was skipped for lacking the attribute.
28
+ - `-w`/`--max-width` no longer cuts an astral character in half.
29
+ - Binary detection now also applies to standard input.
30
+ - Slash-containing globs (e.g. `--include 'src/*.html'`) now match on Windows:
31
+ paths are normalized to `/` separators before glob matching.
32
+ - Recursive walks visit directory entries in sorted order, so output order is
33
+ deterministic across platforms.
34
+
35
+ ### Changed
36
+ - Bare `--color` now means `auto`, matching GNU grep (was `always`).
37
+ - `-c` prints zero counts (`file:0`, or a lone `0` for a single input),
38
+ matching grep.
39
+
40
+ ## [1.1.0] - 2026-07-02
41
+
42
+ ### Added
43
+ - `-H`/`--with-filename` and `--no-filename` to force the `file:` prefix on or off.
44
+ - `-s`/`--no-messages` to suppress errors for unreadable or missing files.
45
+ - `--include`/`--exclude` file globs with brace alternation (`{a,b,c}`).
46
+ `--exclude` is an alias of `--ignore`; `--include` replaces the `--ext` filter.
47
+ - `--max-depth <n>` to cap `-r` recursion depth.
48
+ - Automatic skipping of binary files (NUL byte / control-byte heuristic), with a
49
+ note on stderr suppressible by `-s`/`-q`.
50
+
51
+ ### Changed
52
+ - CI now runs on Linux, macOS and Windows across Node 20 and 22.
53
+ - Releases publish to npm with provenance and smoke-test the built binary.
54
+
55
+ ## [1.0.0] - 2026-06-29
56
+
57
+ ### Added
58
+ - Initial release: match a CSS selector against HTML and print each hit
59
+ grep-style, with byte-accurate `line:col` via `-n` (works on minified HTML).
60
+ - Recursive search: `-r`/`--recursive`, `--ext`, `-i`/`--ignore`, `--ignore-file`.
61
+ - Print modes: default line, `-p`/`--print`, `--attr`, `--text`, `--json`.
62
+ - `--parent <n>` structural context; line context `-A`/`-B`/`-C`.
63
+ - Limits `-m`/`--max-count`, `-M`/`--max-total`; aggregates `-c`, `-l`/`-L`, `-q`.
64
+ - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
65
+ - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
66
+
67
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...HEAD
68
+ [1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
69
+ [1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
70
+ [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,12 +77,16 @@ 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`. |
86
+ | `-S`, `--follow` | Follow symbolic links while recursing with `-r` (they are skipped by default). Each physical directory is visited once, so symlink cycles are safe. |
79
87
  | `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
80
88
  | `-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`. |
89
+ | `--attr <name>` | Print the value of attribute `<name>` for each match (nodes without it are skipped; if every match is skipped the exit status is 1). The name is matched case-insensitively. Honors `-n` and `-w`. |
82
90
  | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
83
91
  | `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text`. |
84
92
  | `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
@@ -87,19 +95,31 @@ locator appears only with `-n`:
87
95
  | `-B`, `--before-context <n>` | Print `n` source lines before each match. |
88
96
  | `-C`, `--context <n>` | Print `n` source lines before and after each match. |
89
97
  | `-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). |
98
+ | `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. The budget counts matches, not files — combined with `-l`/`-L`, scanning stops once `n` matches have been seen, which can cut the file list short. |
99
+ | `-c`, `--count` | Print only the match count (per file when relevant, zeros included — `file:0`, like grep). |
92
100
  | `-l`, `--files-with-matches` | Print only the names of files that contain a match. |
93
101
  | `-L`, `--files-without-match` | Print only the names of files with no match. |
94
102
  | `-q`, `--quiet` | Print nothing; exit `0` on the first match, `1` if none. Stops early. |
103
+ | `-s`, `--no-messages` | Suppress error messages for unreadable or missing files (handy with `-r`). |
95
104
  | `-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`. |
105
+ | `-H`, `--with-filename` | Always print the `file:` prefix, even for a single file or stdin. |
106
+ | `--no-filename` | Never print the `file:` prefix, even when searching multiple files. |
107
+ | `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `auto`, like grep; use `--color=always` to force color into pipes. |
97
108
  | `-h`, `--help` | Show help. |
98
109
  | `-V`, `--version` | Print the version and exit. |
99
110
 
100
111
  Boolean short flags can be combined into one token (`-rn` is `-r -n`), and a
101
112
  value-taking flag may close such a cluster (`-rnw100`).
102
113
 
114
+ Globs for `--include`/`--ignore`/`--exclude` support `*` (within a path
115
+ segment), `**` (across `/`), `?` (one non-slash char), and brace alternation
116
+ like `*.{html,htm}`. A trailing `/` matches directories only; a pattern with a
117
+ `/` matches against the path, otherwise the basename — gitignore-flavored.
118
+
119
+ Binary input is detected (a NUL byte or a high ratio of control bytes in the
120
+ first 8 KB) and skipped with a note on stderr — parsing them as HTML is never
121
+ useful. Suppress the note with `-s` (or `-q`).
122
+
103
123
  When coloring is on, the matched node is highlighted within its line (grep's
104
124
  bold-red); the `file:` prefix and `line:col` locator get their own colors
105
125
  (magenta and green, like grep). Plain `-p` prints no color (the whole block is
@@ -187,4 +207,6 @@ a flag.)
187
207
  - [`css-select`](https://github.com/fb55/css-select) matches the selector
188
208
  against that DOM (the same engine cheerio uses).
189
209
  - Offsets are converted to 1-based `line:col` via a precomputed line index;
190
- `col` points at the opening `<` of the matched tag.
210
+ `col` points at the opening `<` of the matched tag and counts *bytes* — what
211
+ vim's `grepformat` `%c` expects — so multibyte UTF-8 text earlier on the line
212
+ doesn't skew the cursor.
@@ -6,9 +6,13 @@
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' \
15
+ '(-S --follow)'{-S,--follow}'[follow symbolic links while recursing]' \
12
16
  '(-n --line-number)'{-n,--line-number}'[prefix each match with line:col]' \
13
17
  '(-p --print)'{-p,--print}'[pretty-print the matched node HTML]' \
14
18
  '--attr[print the value of an attribute]:attribute name:' \
@@ -25,7 +29,10 @@ _cssgrep() {
25
29
  '(-l --files-with-matches)'{-l,--files-with-matches}'[print only names of files with a match]' \
26
30
  '(-L --files-without-match)'{-L,--files-without-match}'[print only names of files without a match]' \
27
31
  '(-q --quiet)'{-q,--quiet}'[print nothing; exit on first match]' \
32
+ '(-s --no-messages)'{-s,--no-messages}'[suppress errors for unreadable/missing files]' \
28
33
  '(-0 --null)'{-0,--null}'[separate the file name with a NUL byte]' \
34
+ '(--no-filename)'{-H,--with-filename}'[always print the file name prefix]' \
35
+ '(-H --with-filename)--no-filename[never print the file name prefix]' \
29
36
  '--color=-[colorize output]::when:(auto always never)' \
30
37
  '(-h --help)'{-h,--help}'[show help and exit]' \
31
38
  '(-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 -S --follow -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,9 +2,13 @@
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'
11
+ complete -c cssgrep -s S -l follow -d 'Follow symbolic links while recursing'
8
12
  complete -c cssgrep -s n -l line-number -d 'Prefix each match with line:col'
9
13
  complete -c cssgrep -s p -l print -d 'Pretty-print the matched node HTML'
10
14
  complete -c cssgrep -l attr -x -d 'Print the value of an attribute'
@@ -21,7 +25,10 @@ complete -c cssgrep -s c -l count -d 'Print only a count of matches'
21
25
  complete -c cssgrep -s l -l files-with-matches -d 'Print only names of files with a match'
22
26
  complete -c cssgrep -s L -l files-without-match -d 'Print only names of files without a match'
23
27
  complete -c cssgrep -s q -l quiet -d 'Print nothing; exit on first match'
28
+ complete -c cssgrep -s s -l no-messages -d 'Suppress errors for unreadable/missing files'
24
29
  complete -c cssgrep -s 0 -l null -d 'Separate the file name with a NUL byte'
30
+ complete -c cssgrep -s H -l with-filename -d 'Always print the file name prefix'
31
+ complete -c cssgrep -l no-filename -d 'Never print the file name prefix'
25
32
  complete -c cssgrep -l color -x -a 'auto always never' -d 'Colorize output'
26
33
  complete -c cssgrep -s h -l help -d 'Show help and exit'
27
34
  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.2.0';
16
16
 
17
17
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
18
18
 
@@ -29,9 +29,14 @@ 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).
38
+ -S, --follow Follow symbolic links when recursing with -r
39
+ (default: skip them; loops are detected).
35
40
  -n, --line-number Prefix each match with its line:col (excludes -c, -p).
36
41
  -p, --print Pretty-print the matched node's HTML above its location.
37
42
  --attr <name> Print the value of attribute <name> (skips nodes without it).
@@ -44,12 +49,17 @@ Options:
44
49
  -C, --context <n> Print <n> source lines before and after each match.
45
50
  -m, --max-count <n> Stop after <n> matches per file.
46
51
  -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).
52
+ -c, --count Print only a count of matches (per file when
53
+ relevant, zeros included, like grep).
48
54
  -l, --files-with-matches Print only the names of files that have a match.
49
55
  -L, --files-without-match Print only the names of files with no match.
50
56
  -q, --quiet Print nothing; exit 0 on first match, 1 if none.
57
+ -s, --no-messages Suppress error messages for unreadable/missing files.
51
58
  -0, --null Separate the file name with a NUL byte (for xargs -0).
52
- --color[=<when>] Colorize output: auto (default), always or never.
59
+ -H, --with-filename Always print the file name prefix (even for one file).
60
+ --no-filename Never print the file name prefix (even for many files).
61
+ --color[=<when>] Colorize output: auto (default, also what a bare
62
+ --color means, like grep), always or never.
53
63
  -h, --help Show this help.
54
64
  -V, --version Show version and exit.
55
65
 
@@ -57,6 +67,9 @@ Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
57
67
  (-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
58
68
  value with = or as the next word (--max-width=100, --ext htm).
59
69
 
70
+ Globs (--include/--ignore/--exclude) support *, ** (crosses /), ?, and brace
71
+ alternation like *.{html,htm}.
72
+
60
73
  Exit status: 0 if any match was found, 1 if none, 2 on error.`;
61
74
 
62
75
  function fail(msg) {
@@ -83,7 +96,10 @@ function parseArgs(argv) {
83
96
  positionals: [],
84
97
  paths: [],
85
98
  recursive: false,
99
+ follow: false,
86
100
  exts: ['html', 'htm'],
101
+ extGiven: false,
102
+ maxDepth: 0,
87
103
  lineNumber: false,
88
104
  print: false,
89
105
  maxWidth: 0,
@@ -101,9 +117,14 @@ function parseArgs(argv) {
101
117
  before: 0,
102
118
  after: 0,
103
119
  ignore: [],
120
+ include: [],
121
+ withFilename: false,
122
+ noFilename: false,
123
+ noMessages: false,
104
124
  color: 'auto',
105
125
  };
106
126
  const setExts = v => {
127
+ opts.extGiven = true;
107
128
  opts.exts = (v || '').split(',').map(s => s.trim().replace(/^\./, '')).filter(Boolean);
108
129
  };
109
130
  const boundedInt = (v, what, min) => {
@@ -115,11 +136,13 @@ function parseArgs(argv) {
115
136
  const setMaxWidth = v => { opts.maxWidth = positiveInt(v, '--max-width'); };
116
137
  const setMaxCount = v => { opts.maxCount = positiveInt(v, '--max-count'); };
117
138
  const setMaxTotal = v => { opts.maxTotal = positiveInt(v, '--max-total'); };
139
+ const setMaxDepth = v => { opts.maxDepth = positiveInt(v, '--max-depth'); };
118
140
  const setParent = v => { opts.parent = positiveInt(v, '--parent'); };
119
141
  const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
120
142
  const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
121
143
  const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
122
144
  const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
145
+ const addInclude = v => { const c = compileIgnore(v); if (c) opts.include.push(c); };
123
146
  const addIgnoreFile = v => {
124
147
  let content;
125
148
  try { content = fs.readFileSync(v, 'utf8'); }
@@ -135,16 +158,22 @@ function parseArgs(argv) {
135
158
  const a = argv[i];
136
159
 
137
160
  // 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").
161
+ // from the next argument (except --color, where a bare flag means "auto",
162
+ // as in GNU grep).
139
163
  if (a.startsWith('--') && a.length > 2) {
140
164
  const eq = a.indexOf('=');
141
165
  const name = eq === -1 ? a : a.slice(0, eq);
142
166
  const inline = eq === -1 ? null : a.slice(eq + 1);
143
- const value = () => (inline != null ? inline : argv[++i]);
167
+ const value = () => {
168
+ if (inline != null) return inline;
169
+ if (i + 1 >= argv.length) fail(`option ${name} requires a value`);
170
+ return argv[++i];
171
+ };
144
172
  switch (name) {
145
173
  case '--help': process.stdout.write(USAGE + '\n'); process.exit(0); break;
146
174
  case '--version': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
147
175
  case '--recursive': opts.recursive = true; break;
176
+ case '--follow': opts.follow = true; break;
148
177
  case '--line-number': opts.lineNumber = true; break;
149
178
  case '--print': opts.print = true; break;
150
179
  case '--count': opts.count = true; break;
@@ -152,20 +181,26 @@ function parseArgs(argv) {
152
181
  case '--files-without-match': opts.filesWithoutMatch = true; break;
153
182
  case '--quiet': case '--silent': opts.quiet = true; break;
154
183
  case '--null': opts.nul = true; break;
184
+ case '--with-filename': opts.withFilename = true; break;
185
+ case '--no-filename': opts.noFilename = true; break;
186
+ case '--no-messages': opts.noMessages = true; break;
155
187
  case '--ext': setExts(value()); break;
156
- case '--ignore': addIgnore(value()); break;
188
+ case '--ignore': case '--exclude': addIgnore(value()); break;
157
189
  case '--ignore-file': addIgnoreFile(value()); break;
190
+ case '--include': addInclude(value()); break;
158
191
  case '--max-width': setMaxWidth(value()); break;
159
192
  case '--max-count': setMaxCount(value()); break;
160
193
  case '--max-total': setMaxTotal(value()); break;
161
- case '--attr': opts.attr = value(); break;
194
+ case '--max-depth': setMaxDepth(value()); break;
195
+ // htmlparser2 lowercases HTML attribute names, so match case-insensitively.
196
+ case '--attr': opts.attr = value().toLowerCase(); break;
162
197
  case '--text': opts.text = true; break;
163
198
  case '--json': opts.json = true; break;
164
199
  case '--parent': setParent(value()); break;
165
200
  case '--after-context': setAfter(value()); break;
166
201
  case '--before-context': setBefore(value()); break;
167
202
  case '--context': setContext(value()); break;
168
- case '--color': case '--colour': opts.color = inline != null ? inline : 'always'; break;
203
+ case '--color': case '--colour': opts.color = inline != null ? inline : 'auto'; break;
169
204
  default: fail(`unknown option: ${name}`);
170
205
  }
171
206
  continue;
@@ -179,6 +214,7 @@ function parseArgs(argv) {
179
214
  const ch = a[j];
180
215
  if (shortValueFlags[ch]) {
181
216
  const rest = a.slice(j + 1); // attached value, if any
217
+ if (rest === '' && i + 1 >= argv.length) fail(`option -${ch} requires a value`);
182
218
  shortValueFlags[ch](rest !== '' ? rest : argv[++i]);
183
219
  break; // value swallowed the cluster tail
184
220
  }
@@ -186,6 +222,7 @@ function parseArgs(argv) {
186
222
  case 'h': process.stdout.write(USAGE + '\n'); process.exit(0); break;
187
223
  case 'V': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
188
224
  case 'r': opts.recursive = true; break;
225
+ case 'S': opts.follow = true; break;
189
226
  case 'n': opts.lineNumber = true; break;
190
227
  case 'p': opts.print = true; break;
191
228
  case 'c': opts.count = true; break;
@@ -193,6 +230,8 @@ function parseArgs(argv) {
193
230
  case 'L': opts.filesWithoutMatch = true; break;
194
231
  case 'q': opts.quiet = true; break;
195
232
  case '0': case 'Z': opts.nul = true; break;
233
+ case 'H': opts.withFilename = true; break;
234
+ case 's': opts.noMessages = true; break;
196
235
  default: fail(`unknown option: -${ch}`);
197
236
  }
198
237
  }
@@ -206,6 +245,10 @@ function parseArgs(argv) {
206
245
  const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
207
246
  .filter(Boolean).length;
208
247
  if (aggregates > 1) fail('only one of -c, -l, -L, -q may be given');
248
+ // Filename prefix can be forced on or off, but not both (grep -H / -h).
249
+ if (opts.withFilename && opts.noFilename) fail('-H cannot be combined with --no-filename');
250
+ // --include fully replaces the extension filter, so the two can't coexist.
251
+ if (opts.extGiven && opts.include.length) fail('--ext and --include cannot be combined');
209
252
  // Per-match print modes choose what is printed for each match; only one.
210
253
  const printModes = [opts.print, opts.attr != null, opts.text, opts.json].filter(Boolean).length;
211
254
  if (printModes > 1) fail('only one of -p, --attr, --text, --json may be given');
@@ -280,7 +323,12 @@ function offsetToPosition(starts, src, offset) {
280
323
  if (text.endsWith('\r')) text = text.slice(0, -1);
281
324
  return {
282
325
  line: lo + 1, // 1-based
326
+ // col in UTF-16 code units: a JS string index into `text`, used by the
327
+ // highlight math. bcol in bytes: what gets printed — vim's grepformat %c
328
+ // and terminals count bytes, so non-ASCII text before the match would
329
+ // otherwise land the cursor short.
283
330
  col: offset - lineStart + 1, // 1-based
331
+ bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
284
332
  text,
285
333
  };
286
334
  }
@@ -296,10 +344,18 @@ function lineTextAt(starts, src, lineNo) {
296
344
  return { lineStart, text };
297
345
  }
298
346
 
347
+ // Never cut between the halves of a surrogate pair: a lone half is invalid
348
+ // Unicode and serializes as U+FFFD garbage. Backing off one unit loses at most
349
+ // one display column.
350
+ function safeCut(text, at) {
351
+ const c = text.charCodeAt(at - 1);
352
+ return c >= 0xd800 && c <= 0xdbff ? at - 1 : at;
353
+ }
354
+
299
355
  function truncate(text, maxWidth) {
300
356
  if (!maxWidth || text.length <= maxWidth) return text;
301
- if (maxWidth <= 1) return text.slice(0, maxWidth);
302
- return text.slice(0, maxWidth - 1) + '…'; // …
357
+ if (maxWidth <= 1) return text.slice(0, safeCut(text, maxWidth));
358
+ return text.slice(0, safeCut(text, maxWidth - 1)) + '…'; // …
303
359
  }
304
360
 
305
361
  // Produce the visible match text: truncate first (so --max-width still measures
@@ -478,7 +534,7 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
478
534
  if (label) prefix += c(COLORS.file, label) + (opts.nul ? '\0' : sepColored);
479
535
  if (opts.lineNumber) {
480
536
  prefix += c(COLORS.line, String(L));
481
- prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.col)) + ' '
537
+ prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
482
538
  : c(COLORS.sep, '-');
483
539
  }
484
540
  const body = m
@@ -510,10 +566,11 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
510
566
  const cap = Math.min(opts.maxCount || Infinity, limit);
511
567
  const limited = Number.isFinite(cap) ? matches.slice(0, cap) : matches;
512
568
  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
- }
569
+ // grep parity: every searched file reports a count, zeros included, so
570
+ // scripts get one row per file (exit status still says whether anything
571
+ // matched at all).
572
+ const fileSep = opts.nul ? '\0' : ':';
573
+ out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
517
574
  return limited.length;
518
575
  }
519
576
  const starts = opts.print ? null : lineIndex(src);
@@ -534,6 +591,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
534
591
  emitContext(src, starts, name, showLabel, targets, opts, out);
535
592
  return limited.length;
536
593
  }
594
+ // From here on the return value is the number of *emitted* records (which is
595
+ // what the -M budget and the exit status should count): --attr can skip
596
+ // matches lacking the attribute, and --parent dedup can merge several
597
+ // matches into one printed ancestor.
537
598
  if (opts.json) {
538
599
  // NDJSON: one self-contained record per match. `html` is the exact source
539
600
  // slice; newlines are escaped by JSON.stringify, so each record stays on
@@ -545,18 +606,20 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
545
606
  out.push(JSON.stringify({
546
607
  file: name,
547
608
  line: pos.line,
548
- col: pos.col,
609
+ col: pos.bcol,
549
610
  html: src.slice(off, nodeEnd),
550
611
  text: collapseWs(textOf(el)),
551
612
  }));
552
613
  }
553
- return limited.length;
614
+ return targets.length;
554
615
  }
616
+ let emitted = 0;
555
617
  for (const el of targets) {
556
618
  if (opts.print) {
557
619
  // -p shows the re-indented node only; no line:col locator. With --parent,
558
620
  // the original matched descendants are highlighted inside the container.
559
621
  out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
622
+ emitted++;
560
623
  continue;
561
624
  }
562
625
  const off = el.startIndex == null ? 0 : el.startIndex;
@@ -586,35 +649,53 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
586
649
  let prefix = '';
587
650
  if (label) prefix += c(COLORS.file, label) + fileSep;
588
651
  if (opts.lineNumber) {
589
- prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.col)) + ' ';
652
+ prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
590
653
  }
591
654
  out.push(prefix + text);
655
+ emitted++;
592
656
  }
593
- return limited.length;
657
+ return emitted;
594
658
  }
595
659
 
596
660
  // Translate a glob to a regex body. `*` matches within a path segment, `**`
597
- // across segments, `?` a single non-slash char; everything else is literal.
661
+ // across segments, `?` a single non-slash char, `{a,b,c}` alternation; every
662
+ // other char is literal.
598
663
  function globToRegex(glob) {
599
664
  let re = '';
665
+ let depth = 0; // open `{` alternation groups
600
666
  for (let i = 0; i < glob.length; i++) {
601
667
  const c = glob[i];
602
668
  if (c === '*') {
603
669
  if (glob[i + 1] === '*') { // ** (crosses /)
604
- re += '.*';
605
670
  i++;
606
- if (glob[i + 1] === '/') i++; // consume the slash in **/
671
+ if (glob[i + 1] === '/') {
672
+ // `**/` spans whole segments (or none): `**/foo` matches `foo` and
673
+ // `a/b/foo`, but not `barfoo` — the `.*` must end at a `/`.
674
+ re += '(?:.*/)?';
675
+ i++;
676
+ } else {
677
+ re += '.*';
678
+ }
607
679
  } else {
608
680
  re += '[^/]*';
609
681
  }
610
682
  } else if (c === '?') {
611
683
  re += '[^/]';
612
- } else if (/[.+^${}()|[\]\\]/.test(c)) {
684
+ } else if (c === '{') {
685
+ re += '(';
686
+ depth++;
687
+ } else if (c === '}' && depth > 0) {
688
+ re += ')';
689
+ depth--;
690
+ } else if (c === ',' && depth > 0) {
691
+ re += '|';
692
+ } else if (/[.+^$()|[\]\\]/.test(c)) { // note: { } handled above
613
693
  re += '\\' + c;
614
694
  } else {
615
695
  re += c;
616
696
  }
617
697
  }
698
+ while (depth-- > 0) re += ')'; // close any unbalanced `{`
618
699
  return re;
619
700
  }
620
701
 
@@ -631,37 +712,87 @@ function compileIgnore(pattern) {
631
712
  return { re, dirOnly, hasSlash };
632
713
  }
633
714
 
634
- function isIgnored(name, full, isDir, matchers) {
715
+ // True if name/path matches any compiled glob matcher (shared by --ignore/
716
+ // --exclude and --include). Globs always use `/` as the separator (gitignore
717
+ // semantics), so normalize Windows backslash paths before matching.
718
+ function matchesAny(name, full, isDir, matchers) {
719
+ const fullPosix = path.sep === '/' ? full : full.split(path.sep).join('/');
635
720
  for (const m of matchers) {
636
721
  if (m.dirOnly && !isDir) continue;
637
- if (m.re.test(m.hasSlash ? full : name)) return true;
722
+ if (m.re.test(m.hasSlash ? fullPosix : name)) return true;
638
723
  }
639
724
  return false;
640
725
  }
641
726
 
642
- function* walk(dir, opts) {
727
+ // `visited` (only with -S/--follow) holds the realpath of every directory
728
+ // already entered, so symlink cycles — and two links to the same physical
729
+ // directory — are traversed once. Without --follow, symlinks are skipped.
730
+ function* walk(dir, opts, depth = 1, visited = null) {
731
+ if (opts.follow && visited === null) {
732
+ visited = new Set();
733
+ try { visited.add(fs.realpathSync(dir)); } catch (e) { /* walked anyway */ }
734
+ }
643
735
  let entries;
644
736
  try {
645
737
  entries = fs.readdirSync(dir, { withFileTypes: true });
646
738
  } catch (e) {
647
- process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
739
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
648
740
  return;
649
741
  }
742
+ // readdir order is filesystem-dependent; sort so output order (and therefore
743
+ // -M/-m truncation points) is stable across platforms.
744
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
650
745
  for (const e of entries) {
651
746
  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;
747
+ let isDir = e.isDirectory();
748
+ let isFile = e.isFile();
749
+ if (opts.follow && e.isSymbolicLink()) {
750
+ const st = fs.statSync(full, { throwIfNoEntry: false }); // resolves the link
751
+ if (!st) continue; // broken link
752
+ isDir = st.isDirectory();
753
+ isFile = st.isFile();
754
+ }
755
+ if (opts.ignore.length && matchesAny(e.name, full, isDir, opts.ignore)) continue;
654
756
  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;
757
+ if (opts.follow) {
758
+ let real;
759
+ try { real = fs.realpathSync(full); } catch (err) { continue; }
760
+ if (visited.has(real)) continue; // cycle or already-walked dir
761
+ visited.add(real);
762
+ }
763
+ // --max-depth caps how far we descend; depth 1 = the target's children.
764
+ if (!opts.maxDepth || depth < opts.maxDepth) yield* walk(full, opts, depth + 1, visited);
765
+ } else if (isFile) {
766
+ // --include replaces the extension filter; otherwise filter by --ext.
767
+ if (opts.include.length) {
768
+ if (matchesAny(e.name, full, false, opts.include)) yield full;
769
+ } else {
770
+ const ext = path.extname(e.name).slice(1).toLowerCase();
771
+ if (opts.exts.includes(ext)) yield full;
772
+ }
659
773
  }
660
774
  }
661
775
  }
662
776
 
663
777
  function readStdin() {
664
- return fs.readFileSync(0, 'utf8');
778
+ return fs.readFileSync(0); // Buffer: sniffed for binary before decoding
779
+ }
780
+
781
+ // Heuristic binary-file detector (grep/git style). A NUL byte in the first 8 KB
782
+ // is a decisive binary signal; failing that, a high ratio of non-text control
783
+ // bytes flags binary content that happens to lack NULs. HTML is text, so binary
784
+ // files are never worth parsing and are skipped. (stat() can't tell us this —
785
+ // only the bytes can.)
786
+ const BINARY_SNIFF_BYTES = 8192;
787
+ function looksBinary(buf) {
788
+ const n = Math.min(buf.length, BINARY_SNIFF_BYTES);
789
+ let suspicious = 0;
790
+ for (let i = 0; i < n; i++) {
791
+ const b = buf[i];
792
+ if (b === 0) return true; // NUL: definitely binary
793
+ if (b < 9 || (b > 13 && b < 32)) suspicious++; // control char (not \t\n\v\f\r)
794
+ }
795
+ return n > 0 && suspicious / n > 0.3;
665
796
  }
666
797
 
667
798
  function main() {
@@ -676,7 +807,7 @@ function main() {
676
807
  const targets = opts.paths.length ? opts.paths : ['.'];
677
808
  for (const p of targets) {
678
809
  const st = fs.statSync(p, { throwIfNoEntry: false });
679
- if (!st) { process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
810
+ if (!st) { if (!opts.noMessages) process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
680
811
  if (st.isDirectory()) files.push(...walk(p, opts));
681
812
  else files.push(p);
682
813
  }
@@ -686,8 +817,11 @@ function main() {
686
817
  useStdin = true;
687
818
  }
688
819
 
689
- // A label (file prefix) is shown when searching more than one file.
690
- const showLabel = !useStdin && files.length > 1;
820
+ // A label (file prefix) is shown when searching more than one file; -H forces
821
+ // it on (even for one file or stdin) and --no-filename forces it off.
822
+ const showLabel = opts.withFilename ? true
823
+ : opts.noFilename ? false
824
+ : (!useStdin && files.length > 1);
691
825
 
692
826
  const out = [];
693
827
  let total = 0;
@@ -697,18 +831,31 @@ function main() {
697
831
 
698
832
  try {
699
833
  if (useStdin) {
700
- total += searchSource(readStdin(), '(standard input)', false, opts, out, room());
834
+ const buf = readStdin();
835
+ if (looksBinary(buf)) {
836
+ if (!opts.noMessages && !opts.quiet) {
837
+ process.stderr.write('cssgrep: (standard input): binary input (skipped)\n');
838
+ }
839
+ } else {
840
+ total += searchSource(buf.toString('utf8'), '(standard input)', showLabel, opts, out, room());
841
+ }
701
842
  } else {
702
843
  for (const f of files) {
703
844
  if (room() <= 0) break; // -M: global budget exhausted
704
- let src;
845
+ let buf;
705
846
  try {
706
- src = fs.readFileSync(f, 'utf8');
847
+ buf = fs.readFileSync(f); // Buffer: sniff before decoding
707
848
  } catch (e) {
708
- process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
849
+ if (!opts.noMessages) process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
850
+ continue;
851
+ }
852
+ if (looksBinary(buf)) {
853
+ if (!opts.noMessages && !opts.quiet) {
854
+ process.stderr.write(`cssgrep: ${f}: binary file (skipped)\n`);
855
+ }
709
856
  continue;
710
857
  }
711
- total += searchSource(src, f, showLabel, opts, out, room());
858
+ total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
712
859
  if (opts.quiet && total > 0) break; // -q: first match decides the status
713
860
  }
714
861
  }
@@ -731,8 +878,11 @@ function main() {
731
878
  }
732
879
  // Normally success means "a match was found". With -L it means "a file
733
880
  // without a match was printed", which is decoupled from the match total.
881
+ // Set exitCode rather than calling process.exit(): stdout writes to a pipe
882
+ // are async, and exit() would drop whatever hasn't flushed yet, truncating
883
+ // large result sets mid-stream.
734
884
  const success = opts.filesWithoutMatch ? out.length > 0 : total > 0;
735
- process.exit(success ? 0 : 1);
885
+ process.exitCode = success ? 0 : 1;
736
886
  }
737
887
 
738
888
  main();
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.2.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 input, including standard
37
+ input (a NUL byte or a high ratio of control bytes in the first 8 KB), is
38
+ skipped with a note on 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 ,
@@ -67,10 +91,21 @@ one per line
67
91
  comments and blank lines ignored), like a
68
92
  .IR .gitignore .
69
93
  .TP
94
+ .BR \-S ", " \-\-follow
95
+ Follow symbolic links while recursing with
96
+ .B \-r
97
+ (they are skipped by default). Each physical directory is visited once, so
98
+ symlink cycles are safe.
99
+ .TP
70
100
  .BR \-n ", " \-\-line\-number
71
101
  Prefix each match with its
72
102
  .IR line : col
73
- locator. Excludes
103
+ locator. The column counts bytes (what vim's
104
+ .B grepformat
105
+ .I %c
106
+ expects), pointing at the opening
107
+ .B <
108
+ of the matched tag. Excludes
74
109
  .B \-c
75
110
  and
76
111
  .BR \-p .
@@ -83,7 +118,8 @@ locator is shown.
83
118
  .BI \-\-attr " name"
84
119
  Print the value of attribute
85
120
  .I name
86
- for each match (nodes without it are skipped).
121
+ for each match (nodes without it are skipped; if every match is skipped the
122
+ exit status is 1). The name is matched case-insensitively.
87
123
  .TP
88
124
  .B \-\-text
89
125
  Print the matched node's text content, whitespace collapsed.
@@ -125,10 +161,17 @@ matches per file.
125
161
  .BR \-M ", " \-\-max\-total " \fIn\fR"
126
162
  Stop after
127
163
  .I n
128
- matches in total across all files.
164
+ matches in total across all files. The budget counts matches, not files;
165
+ combined with
166
+ .B \-l
167
+ or
168
+ .B \-L
169
+ scanning stops once the budget is spent, which can cut the file list short.
129
170
  .TP
130
171
  .BR \-c ", " \-\-count
131
- Print only a count of matches (per file when relevant).
172
+ Print only a count of matches (per file when relevant). Files with no match
173
+ report a zero count, as in
174
+ .BR grep .
132
175
  .TP
133
176
  .BR \-l ", " \-\-files\-with\-matches
134
177
  Print only the names of files that have a match.
@@ -139,10 +182,19 @@ Print only the names of files with no match.
139
182
  .BR \-q ", " \-\-quiet
140
183
  Print nothing; exit 0 on the first match, 1 if none.
141
184
  .TP
185
+ .BR \-s ", " \-\-no\-messages
186
+ Suppress error messages for unreadable or missing files.
187
+ .TP
142
188
  .BR \-0 ", " \-\-null
143
189
  Separate the file name with a NUL byte (for
144
190
  .BR "xargs \-0" ).
145
191
  .TP
192
+ .BR \-H ", " \-\-with\-filename
193
+ Always print the file name prefix, even for a single file or standard input.
194
+ .TP
195
+ .B \-\-no\-filename
196
+ Never print the file name prefix, even when searching multiple files.
197
+ .TP
146
198
  .BR \-\-color [ =\fIwhen\fR ]
147
199
  Colorize output:
148
200
  .I auto
@@ -151,7 +203,12 @@ Colorize output:
151
203
  A bare
152
204
  .B \-\-color
153
205
  means
154
- .IR always .
206
+ .IR auto ,
207
+ as in
208
+ .BR grep ;
209
+ use
210
+ .B \-\-color=always
211
+ to force color into a pipe.
155
212
  .TP
156
213
  .BR \-h ", " \-\-help
157
214
  Show help and exit.
@@ -169,6 +226,12 @@ Long options take a value with
169
226
  .B =
170
227
  or as the next word
171
228
  .RB ( \-\-max\-width=100 ", " "\-\-ext htm" ).
229
+ .PP
230
+ Globs for
231
+ .BR \-\-include ", " \-\-ignore ", and " \-\-exclude
232
+ support
233
+ .BR * " (within a path segment), " ** " (across " / ),
234
+ .BR ? " (one non-slash char), and brace alternation like " *.{html,htm} .
172
235
  .SH EXIT STATUS
173
236
  .TP
174
237
  .B 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.0.0",
3
+ "version": "1.2.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/"