cssgrep 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.8.0] - 2026-07-13
11
+
12
+ ### Changed
13
+ - The `-n` locator is now colon-separated from the text —
14
+ `file:line:col:text` instead of `file:line:col text` — matching grep's
15
+ separator and making the output byte-compatible with `rg --vimgrep`. Vim
16
+ users can use the standard `grepformat=%f:%l:%c:%m` (docs updated); scripts
17
+ parsing the old space-separated form need the one-character change.
18
+
19
+ ## [1.7.0] - 2026-07-12
20
+
21
+ ### Added
22
+ - `-p` output is syntax-highlighted when color is on: tag names, attribute
23
+ names, attribute values and comments each get a color, and with
24
+ `--parent -p` the matched node's region stays wrapped in unbroken match
25
+ red on top of it. `--color=never` (or piping) keeps the plain output.
26
+
10
27
  ## [1.6.0] - 2026-07-12
11
28
 
12
29
  ### Added
@@ -144,7 +161,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
144
161
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
145
162
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
146
163
 
147
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.6.0...HEAD
164
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.8.0...HEAD
165
+ [1.8.0]: https://github.com/msbatarce/cssgrep/compare/v1.7.0...v1.8.0
166
+ [1.7.0]: https://github.com/msbatarce/cssgrep/compare/v1.6.0...v1.7.0
148
167
  [1.6.0]: https://github.com/msbatarce/cssgrep/compare/v1.5.0...v1.6.0
149
168
  [1.5.0]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...v1.5.0
150
169
  [1.4.0]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...v1.4.0
package/README.md CHANGED
@@ -63,14 +63,15 @@ cat page.html | cssgrep <selector> # read from stdin
63
63
 
64
64
  Like `grep`, each matching line is printed on its own, once — however many
65
65
  matches sit on it. With `-n` there is one record *per match*, each with its
66
- own `line:col` locator (that per-match precision is the point of the tool). A
66
+ own `line:col` locator (that per-match precision is the point of the tool),
67
+ colon-separated from the text — byte-compatible with `rg --vimgrep`. A
67
68
  `file:` prefix is added when searching multiple files:
68
69
 
69
70
  ```
70
71
  {line contents} # default; stdin or single file
71
72
  {file}:{line contents} # default; multiple files
72
- {line}:{col} {line contents} # -n; stdin or single file
73
- {file}:{line}:{col} {line contents} # -n; multiple files
73
+ {line}:{col}:{line contents} # -n; stdin or single file
74
+ {file}:{line}:{col}:{line contents} # -n; multiple files
74
75
  ```
75
76
 
76
77
  ### Options
@@ -87,7 +88,7 @@ own `line:col` locator (that per-match precision is the point of the tool). A
87
88
  | `--ignore-file <path>` | Load ignore globs from a file, one per line (`#` comments and blank lines ignored) — like a `.gitignore`. |
88
89
  | `-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. |
89
90
  | `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
90
- | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
91
+ | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input) and syntax-highlighted when color is on. No `line:col` locator is shown. |
91
92
  | `--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`. |
92
93
  | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
93
94
  | `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `attribs` (the element's attributes, names lowercased), `html`, `text` — plus `label` when `-e` is used. |
@@ -132,9 +133,11 @@ useful. Suppress the note with `-s` (or `-q`).
132
133
 
133
134
  When coloring is on, the matched node is highlighted within its line (grep's
134
135
  bold-red); the `file:` prefix and `line:col` locator get their own colors
135
- (magenta and green, like grep). Plain `-p` prints no color (the whole block is
136
- the match), but `-p --parent <n>` highlights the original matched node inside
137
- the printed container, so you can see what matched within its surroundings.
136
+ (magenta and green, like grep). `-p` blocks are syntax-highlighted tag
137
+ names, attribute names, attribute values and comments each get a color and
138
+ with `--parent <n>` the original matched node is additionally wrapped in the
139
+ match color inside the printed container (match red wins over syntax within
140
+ its region), so you can see what matched within its surroundings.
138
141
 
139
142
  Exit status: `0` if any match was found, `1` if none, `2` on error — same
140
143
  convention as `grep`.
@@ -322,12 +325,13 @@ message pointing back to these recipes, rather than a bare "unknown option".
322
325
 
323
326
  ## Vim / Neovim integration
324
327
 
325
- The `file:line:col text` format produced by `-n` is `:grep`-compatible. Point
328
+ The `file:line:col:text` format produced by `-n` is byte-compatible with
329
+ `rg --vimgrep`, so the standard ripgrep `grepformat` works as-is. Point
326
330
  `grepprg` at `cssgrep -n -r` and hits land in the quickfix list:
327
331
 
328
332
  ```vim
329
333
  set grepprg=cssgrep\ -n\ -r
330
- set grepformat=%f:%l:%c\ %m
334
+ set grepformat=%f:%l:%c:%m
331
335
 
332
336
  " :grep 'div.card' src/ then :copen
333
337
  ```
package/cli.js CHANGED
@@ -4,8 +4,8 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const {
7
- parse, extractHtmlFragments, lineIndex, offsetToPosition, lineTextAt,
8
- textOf, collapseWs, ancestor,
7
+ parse, extractHtmlFragments, lexOpenTag, lineIndex, offsetToPosition,
8
+ lineTextAt, textOf, collapseWs, ancestor,
9
9
  } = require('./lib.js');
10
10
 
11
11
  // Files with these extensions are scanned for HTML inside JS/TS template
@@ -29,7 +29,7 @@ function loadPrettyPrinter() {
29
29
  // package.json, so it survives compilation into a standalone binary (Bun
30
30
  // --compile / Node SEA), where package.json won't sit next to the executable.
31
31
  // Keep in sync with package.json on release.
32
- const VERSION = '1.6.0';
32
+ const VERSION = '1.8.0';
33
33
 
34
34
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
35
35
 
@@ -39,11 +39,12 @@ Usage:
39
39
  cssgrep -e '[label=]<sel>' [-e ...] [file ...]
40
40
  cat file.html | cssgrep <selector>
41
41
 
42
- Output (each matching line once, like grep; with -n, one record per match):
42
+ Output (each matching line once, like grep; with -n, one record per match,
43
+ byte-compatible with rg --vimgrep):
43
44
  {line contents} (default; stdin or single file)
44
45
  {file}:{line contents} (default; multiple files)
45
- {line}:{col} {line contents} (with -n; stdin or single file)
46
- {file}:{line}:{col} {line contents} (with -n; multiple files)
46
+ {line}:{col}:{line contents} (with -n; stdin or single file)
47
+ {file}:{line}:{col}:{line contents} (with -n; multiple files)
47
48
 
48
49
  Options:
49
50
  -e, --selector <[label=]sel> Add a selector (repeatable). Matches from all
@@ -60,7 +61,8 @@ Options:
60
61
  -S, --follow Follow symbolic links when recursing with -r
61
62
  (default: skip them; loops are detected).
62
63
  -n, --line-number Prefix each match with its line:col (excludes -c, -p).
63
- -p, --print Pretty-print the matched node's HTML above its location.
64
+ -p, --print Pretty-print the matched node's HTML, syntax-
65
+ highlighted when color is on.
64
66
  --attr <name> Print the value of attribute <name> (skips nodes without it).
65
67
  --text Print the matched node's text content (whitespace collapsed).
66
68
  --json Print one JSON record per match (NDJSON: file,line,col,
@@ -137,12 +139,19 @@ function failInvert(flag) {
137
139
  // ANSI SGR codes matching grep's default scheme: bold-red match, magenta
138
140
  // filename, green line/col numbers, cyan separators. The [label] tag from -e
139
141
  // has no grep counterpart; yellow keeps it distinct from all of the above.
142
+ // The last four paint -p's syntax highlighting (tag names, attribute names,
143
+ // attribute values, comments/doctypes) — distinct from match red, which
144
+ // always wins inside a --parent -p match region.
140
145
  const COLORS = {
141
146
  match: '1;31',
142
147
  file: '35',
143
148
  line: '32',
144
149
  sep: '36',
145
150
  label: '33',
151
+ tag: '1;34',
152
+ attr: '36',
153
+ value: '32',
154
+ comment: '90',
146
155
  };
147
156
 
148
157
  function paint(code, str) {
@@ -386,9 +395,8 @@ function parseArgs(argv) {
386
395
  fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
387
396
  }
388
397
  // Resolve the tri-state into a single boolean: color only when forced on, or
389
- // 'auto' and stdout is an interactive terminal. Plain -p still prints no
390
- // color (it has nothing to highlight) that's gated where it prints, not
391
- // here, so --parent -p can highlight the matched node inside the container.
398
+ // 'auto' and stdout is an interactive terminal. -p uses it for syntax
399
+ // highlighting (and --parent -p for the match region on top of it).
392
400
  opts.colorOn = opts.color === 'always' || (opts.color === 'auto' && Boolean(process.stdout.isTTY));
393
401
  return opts;
394
402
  }
@@ -467,14 +475,63 @@ function renderText(pos, off, nodeEnd, opts) {
467
475
  const HL_START = '';
468
476
  const HL_END = '';
469
477
 
478
+ // Syntax-highlight a pretty-printed HTML block: tag names, attribute names,
479
+ // attribute values (quotes included) and comments/doctypes get their own
480
+ // colors; text content and punctuation stay unpainted. Spans come from the
481
+ // same opening-tag lexer the rewrite mode uses, so a quoted '>' never
482
+ // confuses the scan.
483
+ function highlightHtml(s) {
484
+ let out = '';
485
+ let i = 0;
486
+ while (i < s.length) {
487
+ const ch = s[i];
488
+ if (ch === '<' && (s.startsWith('<!--', i) || s[i + 1] === '!')) {
489
+ const close = s.startsWith('<!--', i) ? '-->' : '>';
490
+ let end = s.indexOf(close, i + 2);
491
+ end = end === -1 ? s.length : end + close.length;
492
+ out += paint(COLORS.comment, s.slice(i, end));
493
+ i = end;
494
+ continue;
495
+ }
496
+ if (ch === '<' && s[i + 1] === '/') {
497
+ const m = /^<\/([^\s>]*)([^>]*>?)/.exec(s.slice(i));
498
+ out += '</' + paint(COLORS.tag, m[1]) + m[2];
499
+ i += m[0].length;
500
+ continue;
501
+ }
502
+ if (ch === '<' && /[a-zA-Z]/.test(s[i + 1] || '')) {
503
+ const lx = lexOpenTag(s, i);
504
+ out += '<' + paint(COLORS.tag, s.slice(lx.nameStart, lx.nameEnd));
505
+ let j = lx.nameEnd;
506
+ for (const a of lx.attrs) {
507
+ out += s.slice(j, a.start) + paint(COLORS.attr, s.slice(a.start, a.nameEnd));
508
+ if (a.vStart >= 0) {
509
+ const quote = s[a.vStart - 1];
510
+ const valFrom = quote === '"' || quote === "'" ? a.vStart - 1 : a.vStart;
511
+ out += s.slice(a.nameEnd, valFrom) + paint(COLORS.value, s.slice(valFrom, a.end));
512
+ }
513
+ j = a.end;
514
+ }
515
+ out += s.slice(j, lx.end);
516
+ i = lx.end;
517
+ continue;
518
+ }
519
+ out += ch;
520
+ i++;
521
+ }
522
+ return out;
523
+ }
524
+
470
525
  // Re-indent a matched node's HTML from scratch (so minified input still comes
471
526
  // out readable). dom-serializer turns the parsed node back into a string;
472
- // js-beautify does the formatting. When `origins` (descendant nodes to
473
- // highlight) is given and coloring is on, those nodes are wrapped in the match
474
- // color within the printed block.
527
+ // js-beautify does the formatting. With color on the block is syntax-
528
+ // highlighted; when `origins` (descendant nodes to highlight) is also given,
529
+ // those nodes are wrapped in the match color instead — match red wins over
530
+ // syntax inside its region.
475
531
  function prettyPrint(el, origins, opts) {
476
532
  loadPrettyPrinter();
477
- const highlight = origins && origins.length && opts && opts.colorOn;
533
+ const colorOn = Boolean(opts && opts.colorOn);
534
+ const highlight = origins && origins.length && colorOn;
478
535
  const inserted = [];
479
536
  if (highlight) {
480
537
  // Bracket each origin with sentinel comment nodes among its siblings.
@@ -504,13 +561,23 @@ function prettyPrint(el, origins, opts) {
504
561
  k = kids.indexOf(end); if (k >= 0) kids.splice(k, 1);
505
562
  }
506
563
  }
507
- if (!highlight) return html;
564
+ if (!colorOn) return html;
565
+ if (!highlight) return highlightHtml(html);
566
+ // Split on the sentinel comments: regions outside the match get syntax
567
+ // highlighting, the match region itself gets the match color, unbroken —
568
+ // syntax codes inside would end the red at every token.
508
569
  const startCode = `\x1b[${COLORS.match}m`;
509
570
  const resetCode = '\x1b[0m';
510
- html = html
511
- .replace(new RegExp(`<!--\\s*${HL_START}\\s*-->`, 'g'), startCode)
512
- .replace(new RegExp(`<!--\\s*${HL_END}\\s*-->`, 'g'), resetCode);
513
- return foldStandaloneCodes(html, startCode, resetCode);
571
+ const isStart = new RegExp(`^<!--\\s*${HL_START}\\s*-->$`);
572
+ const isEnd = new RegExp(`^<!--\\s*${HL_END}\\s*-->$`);
573
+ let out = '';
574
+ let inside = false;
575
+ for (const part of html.split(new RegExp(`(<!--\\s*[${HL_START}${HL_END}]\\s*-->)`, 'g'))) {
576
+ if (isStart.test(part)) { out += startCode; inside = true; continue; }
577
+ if (isEnd.test(part)) { out += resetCode; inside = false; continue; }
578
+ out += inside ? part : highlightHtml(part);
579
+ }
580
+ return foldStandaloneCodes(out, startCode, resetCode);
514
581
  }
515
582
 
516
583
  // beautify sometimes parks a marker comment on its own line (e.g. when the
@@ -581,7 +648,7 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
581
648
  if (label) prefix += c(COLORS.file, label) + (opts.nul ? '\0' : sepColored);
582
649
  if (opts.lineNumber) {
583
650
  prefix += c(COLORS.line, String(L));
584
- prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + ' '
651
+ prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.bcol)) + c(COLORS.sep, ':')
585
652
  : c(COLORS.sep, '-');
586
653
  }
587
654
  const tag = m && m.selLabel != null ? c(COLORS.label, `[${m.selLabel}]`) + ' ' : '';
@@ -755,8 +822,8 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity, embedde
755
822
  text = renderText(pos, off, nodeEnd, opts);
756
823
  }
757
824
  // grep-style: a `file:` prefix appears with multiple files; the line:col
758
- // locator only with -n. The locator is separated from the text by a space
759
- // so the output stays :grep-compatible (grepformat %f:%l:%c %m).
825
+ // locator only with -n, colon-separated from the text byte-compatible
826
+ // with `rg --vimgrep` (grepformat %f:%l:%c:%m).
760
827
  const sep = c(COLORS.sep, ':');
761
828
  // -0/--null: the char after the file name becomes NUL (grep -Z), for
762
829
  // unambiguous machine parsing (e.g. xargs -0).
@@ -764,7 +831,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity, embedde
764
831
  let prefix = '';
765
832
  if (label) prefix += c(COLORS.file, label) + fileSep;
766
833
  if (opts.lineNumber) {
767
- prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + ' ';
834
+ prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.bcol)) + sep;
768
835
  }
769
836
  out.push(prefix + tag + text);
770
837
  emitted++;
package/lib.js CHANGED
@@ -523,6 +523,7 @@ module.exports = {
523
523
  parse,
524
524
  // Internal helpers, shared with cli.js; not part of the stable API.
525
525
  extractHtmlFragments,
526
+ lexOpenTag,
526
527
  lineIndex,
527
528
  offsetToPosition,
528
529
  lineTextAt,
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 cli.js.
3
- .TH CSSGREP 1 "2026-07-12" "cssgrep 1.6.0" "User Commands"
3
+ .TH CSSGREP 1 "2026-07-13" "cssgrep 1.8.0" "User Commands"
4
4
  .SH NAME
5
5
  cssgrep \- search HTML by CSS selector, grep-style
6
6
  .SH SYNOPSIS
@@ -144,7 +144,9 @@ and
144
144
  .BR \-p .
145
145
  .TP
146
146
  .BR \-p ", " \-\-print
147
- Pretty-print the matched node's HTML, re-indented from scratch. No
147
+ Pretty-print the matched node's HTML, re-indented from scratch and
148
+ syntax-highlighted when color is on (tag names, attribute names and values,
149
+ comments). No
148
150
  .IR line : col
149
151
  locator is shown.
150
152
  .TP
@@ -441,7 +443,7 @@ locator:
441
443
  .RS
442
444
  .nf
443
445
  set grepprg=cssgrep\e \-n\e \-r
444
- set grepformat=%f:%l:%c\e %m
446
+ set grepformat=%f:%l:%c:%m
445
447
  .fi
446
448
  .RE
447
449
  .SH SEE ALSO
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Search HTML by CSS selector and print matches grep-style (file:line:col with -n).",
5
5
  "main": "lib.js",
6
6
  "types": "index.d.ts",