cssgrep 1.3.0 → 1.5.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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-07-12
11
+
12
+ ### Added
13
+ - `--json` records now include `attribs`, the matched element's attribute
14
+ object (names lowercased by the parser) — the same field the library's
15
+ `Match` exposes, so scraping pipelines get attribute data without a second
16
+ pass (e.g. `jq -r .attribs.href`).
17
+
18
+ ## [1.4.0] - 2026-07-07
19
+
20
+ ### Fixed
21
+ - Large result sets no longer crash with `RangeError: Invalid string length`
22
+ (exit 1, zero output): output is written in byte-bounded chunks instead of
23
+ one giant join. Observed with 40k matches on an 8 MB minified line.
24
+ - Emitting many matches on one physical line was quadratic (two O(line)
25
+ scans per match); the same 40k-locator case dropped from 9.4 s to 0.4 s.
26
+ Found by the new `npm run bench` harness.
27
+
28
+ ### Changed
29
+ - grep parity: without `-n`, a matching line now prints once, however many
30
+ matches sit on it. With `-n` there is still one record per match, each
31
+ with its own `line:col` locator.
32
+ - Startup no longer loads the pretty-printing dependencies unless `-p` is
33
+ used (43.4 → 37.9 ms measured), and zero-match files skip the line index.
34
+
10
35
  ## [1.3.0] - 2026-07-07
11
36
 
12
37
  ### Added
@@ -108,7 +133,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
108
133
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
109
134
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
110
135
 
111
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...HEAD
136
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.5.0...HEAD
137
+ [1.5.0]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...v1.5.0
138
+ [1.4.0]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...v1.4.0
112
139
  [1.3.0]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...v1.3.0
113
140
  [1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
114
141
  [1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
package/README.md CHANGED
@@ -61,9 +61,10 @@ cssgrep <selector> -r <dir ...> # recurse directories
61
61
  cat page.html | cssgrep <selector> # read from stdin
62
62
  ```
63
63
 
64
- Output, one line per match. Like `grep`, the matched line is printed on its own;
65
- a `file:` prefix is added when searching multiple files, and the `line:col`
66
- locator appears only with `-n`:
64
+ Like `grep`, each matching line is printed on its own, once — however many
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
67
+ `file:` prefix is added when searching multiple files:
67
68
 
68
69
  ```
69
70
  {line contents} # default; stdin or single file
@@ -89,7 +90,7 @@ locator appears only with `-n`:
89
90
  | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
90
91
  | `--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`. |
91
92
  | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
92
- | `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text` — plus `label` when `-e` is used. |
93
+ | `--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. |
93
94
  | `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
94
95
  | `-w`, `--max-width <n>` | Truncate the shown line to `n` columns (adds `…`). Value attaches or follows: `-w100`, `-w 100`, `--max-width=100`. |
95
96
  | `-A`, `--after-context <n>` | Print `n` source lines after each match. |
@@ -186,8 +187,8 @@ $ cssgrep -n -e 'title=h1' -e 'price=.card .price' page.html
186
187
  9:12 [price] <span class="price">$4.99</span>
187
188
 
188
189
  $ cssgrep --json -e 'title=h1' -e 'price=.card .price' page.html
189
- {"file":"page.html","line":3,"col":5,"label":"title","html":"<h1>Widget</h1>","text":"Widget"}
190
- {"file":"page.html","line":9,"col":12,"label":"price","html":"...","text":"$4.99"}
190
+ {"file":"page.html","line":3,"col":5,"label":"title","attribs":{},"html":"<h1>Widget</h1>","text":"Widget"}
191
+ {"file":"page.html","line":9,"col":12,"label":"price","attribs":{"class":"price"},"html":"...","text":"$4.99"}
191
192
  ```
192
193
 
193
194
  The label is anything matching `[A-Za-z_][A-Za-z0-9_-]*` before a `=`; since a
package/cli.js CHANGED
@@ -3,17 +3,27 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const render = require('dom-serializer').default;
7
- const { html: beautify } = require('js-beautify');
8
6
  const {
9
7
  parse, offsetToPosition, lineTextAt, textOf, collapseWs, ancestor,
10
8
  } = require('./lib.js');
11
9
 
10
+ // dom-serializer + js-beautify are only needed by -p, and cost ~13 ms of a
11
+ // ~43 ms startup (measured, ROADMAP Phase 11) — loaded on first use so the
12
+ // grepprg-style hot path never pays for them.
13
+ let render = null;
14
+ let beautify = null;
15
+ function loadPrettyPrinter() {
16
+ if (!render) {
17
+ render = require('dom-serializer').default;
18
+ beautify = require('js-beautify').html;
19
+ }
20
+ }
21
+
12
22
  // Single source of truth for the version. A constant rather than a read of
13
23
  // package.json, so it survives compilation into a standalone binary (Bun
14
24
  // --compile / Node SEA), where package.json won't sit next to the executable.
15
25
  // Keep in sync with package.json on release.
16
- const VERSION = '1.3.0';
26
+ const VERSION = '1.5.0';
17
27
 
18
28
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
19
29
 
@@ -23,7 +33,7 @@ Usage:
23
33
  cssgrep -e '[label=]<sel>' [-e ...] [file ...]
24
34
  cat file.html | cssgrep <selector>
25
35
 
26
- Output (one line per match):
36
+ Output (each matching line once, like grep; with -n, one record per match):
27
37
  {line contents} (default; stdin or single file)
28
38
  {file}:{line contents} (default; multiple files)
29
39
  {line}:{col} {line contents} (with -n; stdin or single file)
@@ -48,7 +58,7 @@ Options:
48
58
  --attr <name> Print the value of attribute <name> (skips nodes without it).
49
59
  --text Print the matched node's text content (whitespace collapsed).
50
60
  --json Print one JSON record per match (NDJSON: file,line,col,
51
- html,text; plus label with -e).
61
+ attribs,html,text; plus label with -e).
52
62
  --parent <n> Report the n-th ancestor of each match instead (dedup'd).
53
63
  -w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
54
64
  -A, --after-context <n> Print <n> source lines after each match.
@@ -452,6 +462,7 @@ const HL_END = '';
452
462
  // highlight) is given and coloring is on, those nodes are wrapped in the match
453
463
  // color within the printed block.
454
464
  function prettyPrint(el, origins, opts) {
465
+ loadPrettyPrinter();
455
466
  const highlight = origins && origins.length && opts && opts.colorOn;
456
467
  const inserted = [];
457
468
  if (highlight) {
@@ -529,9 +540,10 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
529
540
  // Map each match line to a representative node span (the first match on it),
530
541
  // which drives the in-line highlight — and the [label] tag — when emitting.
531
542
  const info = new Map();
543
+ const posState = {};
532
544
  for (const { el, label: selLabel } of targets) {
533
545
  const off = el.startIndex == null ? 0 : el.startIndex;
534
- const pos = offsetToPosition(starts, src, off);
546
+ const pos = offsetToPosition(starts, src, off, posState);
535
547
  if (info.has(pos.line)) continue;
536
548
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
537
549
  info.set(pos.line, { off, nodeEnd, pos, selLabel });
@@ -615,6 +627,9 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
615
627
  out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
616
628
  return limited.length;
617
629
  }
630
+ // Nothing to emit: return before building the line index — a zero-match
631
+ // pass over a large file shouldn't pay a full line scan for nothing.
632
+ if (limited.length === 0) return 0;
618
633
  const starts = opts.print ? null : doc.lineStarts();
619
634
  // --parent re-targets matches to ancestors (no-op without it). Dedup is per
620
635
  // (ancestor, label), so two -e selectors sharing a container still report it
@@ -653,19 +668,22 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
653
668
  // matches lacking the attribute, and --parent dedup can merge several
654
669
  // matches into one printed ancestor.
655
670
  if (opts.json) {
656
- // NDJSON: one self-contained record per match. `html` is the exact source
657
- // slice; newlines are escaped by JSON.stringify, so each record stays on
658
- // one line. Ignores --color and -n (line/col are always present). `label`
659
- // appears only with -e.
671
+ // NDJSON: one self-contained record per match. `attribs` mirrors the lib
672
+ // Match's field (names lowercased by the parser) so scraping never needs
673
+ // a second pass; `html` is the exact source slice; newlines are escaped
674
+ // by JSON.stringify, so each record stays on one line. Ignores --color
675
+ // and -n (line/col are always present). `label` appears only with -e.
676
+ const posState = {};
660
677
  for (const { el, label: selLabel } of targets) {
661
678
  const off = el.startIndex == null ? 0 : el.startIndex;
662
- const pos = offsetToPosition(starts, src, off);
679
+ const pos = offsetToPosition(starts, src, off, posState);
663
680
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
664
681
  out.push(JSON.stringify({
665
682
  file: name,
666
683
  line: pos.line,
667
684
  col: pos.bcol,
668
685
  ...(selLabel !== null && { label: selLabel }),
686
+ attribs: el.attribs || {},
669
687
  html: src.slice(off, nodeEnd),
670
688
  text: collapseWs(textOf(el)),
671
689
  }));
@@ -673,6 +691,12 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
673
691
  return targets.length;
674
692
  }
675
693
  let emitted = 0;
694
+ // grep parity: without -n, a physical line prints once no matter how many
695
+ // matches sit on it (grep never repeats a line). With -n each match keeps
696
+ // its own line:col record — that per-match locator is the tool's point.
697
+ // Extraction modes print per-match values, so they never dedup.
698
+ const seenLines = (opts.lineNumber || opts.attr != null || opts.text) ? null : new Set();
699
+ const posState = {};
676
700
  for (const { el, label: selLabel } of targets) {
677
701
  const c = opts.colorOn ? paint : (_, s) => s;
678
702
  // The [label] tag from -e; a null label (positional selector) prints none.
@@ -687,7 +711,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
687
711
  continue;
688
712
  }
689
713
  const off = el.startIndex == null ? 0 : el.startIndex;
690
- const pos = offsetToPosition(starts, src, off);
714
+ const pos = offsetToPosition(starts, src, off, posState);
691
715
 
692
716
  // Choose the content printed for this match. --attr/--text replace the
693
717
  // source line with the extracted value (whole value highlighted as the
@@ -699,6 +723,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
699
723
  } else if (opts.text) {
700
724
  text = c(COLORS.match, truncate(collapseWs(textOf(el)), opts.maxWidth));
701
725
  } else {
726
+ if (seenLines) {
727
+ if (seenLines.has(pos.line)) continue; // this line already printed
728
+ seenLines.add(pos.line);
729
+ }
702
730
  const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1; // exclusive
703
731
  text = renderText(pos, off, nodeEnd, opts);
704
732
  }
@@ -1225,11 +1253,29 @@ function main() {
1225
1253
  // For -l/-L, -0 NUL-terminates each file name (no newline) so the list is
1226
1254
  // safe for `xargs -0`. Other modes keep newline-separated records (with -0
1227
1255
  // the NUL appears only as the in-record file-name separator).
1228
- if (opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch)) {
1229
- process.stdout.write(out.map(s => s + '\0').join(''));
1230
- } else {
1231
- process.stdout.write(out.join('\n') + '\n');
1256
+ //
1257
+ // Written in byte-bounded chunks, never as one join of everything: with
1258
+ // enough (or long enough) result lines a single joined string exceeds
1259
+ // V8's maximum string length and crashes — observed with 40k matches on
1260
+ // an 8 MB minified line. Writes queue; process.exitCode (not exit())
1261
+ // keeps them flush-safe.
1262
+ const nulList = opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch);
1263
+ const CHUNK_BYTES = 32 << 20; // ~32 MB per write
1264
+ let batch = [];
1265
+ let bytes = 0;
1266
+ const flush = () => {
1267
+ if (!batch.length) return;
1268
+ process.stdout.write(nulList ? batch.map(s => s + '\0').join('')
1269
+ : batch.join('\n') + '\n');
1270
+ batch = [];
1271
+ bytes = 0;
1272
+ };
1273
+ for (const line of out) {
1274
+ batch.push(line);
1275
+ bytes += line.length + 1;
1276
+ if (bytes >= CHUNK_BYTES) flush();
1232
1277
  }
1278
+ flush();
1233
1279
  }
1234
1280
  // Normally success means "a match was found". With -L it means "a file
1235
1281
  // without a match was printed", which is decoupled from the match total.
package/lib.js CHANGED
@@ -26,7 +26,7 @@ function lineIndex(src) {
26
26
  return starts;
27
27
  }
28
28
 
29
- function offsetToPosition(starts, src, offset) {
29
+ function offsetToPosition(starts, src, offset, state) {
30
30
  // binary search for the greatest line start <= offset
31
31
  let lo = 0, hi = starts.length - 1;
32
32
  while (lo < hi) {
@@ -35,11 +35,27 @@ function offsetToPosition(starts, src, offset) {
35
35
  else hi = mid - 1;
36
36
  }
37
37
  const lineStart = starts[lo];
38
- let lineEnd = src.indexOf('\n', lineStart);
39
- if (lineEnd === -1) lineEnd = src.length;
38
+ // The next line's start bounds this line (O(1)); an indexOf scan here is
39
+ // O(line length) *per match* — quadratic on minified single-line files.
40
+ const lineEnd = lo + 1 < starts.length ? starts[lo + 1] - 1 : src.length;
40
41
  // strip a trailing \r so CRLF files render cleanly
41
42
  let text = src.slice(lineStart, lineEnd);
42
43
  if (text.endsWith('\r')) text = text.slice(0, -1);
44
+ // bcol counts bytes from the line start — also quadratic if recomputed per
45
+ // match. `state` (one mutable object per source, supplied by the caller)
46
+ // remembers the previous match's byte count, so in-order matches on the
47
+ // same line pay only the delta; out-of-order falls back to a full count.
48
+ let bytes;
49
+ if (state && state.lineStart === lineStart && offset >= state.offset) {
50
+ bytes = state.bytes + Buffer.byteLength(src.slice(state.offset, offset), 'utf8');
51
+ } else {
52
+ bytes = Buffer.byteLength(src.slice(lineStart, offset), 'utf8');
53
+ }
54
+ if (state) {
55
+ state.lineStart = lineStart;
56
+ state.offset = offset;
57
+ state.bytes = bytes;
58
+ }
43
59
  return {
44
60
  line: lo + 1, // 1-based
45
61
  // col in UTF-16 code units: a JS string index into `text`, used by the
@@ -47,7 +63,7 @@ function offsetToPosition(starts, src, offset) {
47
63
  // and terminals count bytes, so non-ASCII text before the match would
48
64
  // otherwise land the cursor short.
49
65
  col: offset - lineStart + 1, // 1-based
50
- bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
66
+ bcol: bytes + 1, // 1-based
51
67
  text,
52
68
  };
53
69
  }
@@ -56,8 +72,7 @@ function offsetToPosition(starts, src, offset) {
56
72
  // line's byte start (so a node's offset maps to a column within it).
57
73
  function lineTextAt(starts, src, lineNo) {
58
74
  const lineStart = starts[lineNo - 1];
59
- let lineEnd = src.indexOf('\n', lineStart);
60
- if (lineEnd === -1) lineEnd = src.length;
75
+ const lineEnd = lineNo < starts.length ? starts[lineNo] - 1 : src.length;
61
76
  let text = src.slice(lineStart, lineEnd);
62
77
  if (text.endsWith('\r')) text = text.slice(0, -1);
63
78
  return { lineStart, text };
@@ -296,7 +311,8 @@ function parse(html) {
296
311
  });
297
312
  let starts = null;
298
313
  const lineStarts = () => starts || (starts = lineIndex(html));
299
- const position = offset => offsetToPosition(lineStarts(), html, offset);
314
+ const posState = {}; // incremental byte-column cache, scoped to this doc
315
+ const position = offset => offsetToPosition(lineStarts(), html, offset, posState);
300
316
  return {
301
317
  html,
302
318
  // Internal (unstable): the raw htmlparser2 document and the cached
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-07" "cssgrep 1.3.0" "User Commands"
3
+ .TH CSSGREP 1 "2026-07-12" "cssgrep 1.5.0" "User Commands"
4
4
  .SH NAME
5
5
  cssgrep \- search HTML by CSS selector, grep-style
6
6
  .SH SYNOPSIS
@@ -46,15 +46,16 @@ skipped with a note on standard error, suppressible with
46
46
  .B \-s
47
47
  or
48
48
  .BR \-q .
49
- One line is printed per match.
50
49
  As with
51
50
  .BR grep ,
52
- the matched line is printed on its own; a
51
+ each matching line is printed on its own, once \(em however many matches sit
52
+ on it. With
53
+ .B \-n
54
+ there is one record per
55
+ .IR match ,
56
+ each with its own locator. A
53
57
  .RI " file :"
54
- prefix is added when searching multiple files, and the
55
- .IR line : col
56
- locator appears only with
57
- .BR \-n .
58
+ prefix is added when searching multiple files.
58
59
  .SH OPTIONS
59
60
  .TP
60
61
  .BR \-e ", " \-\-selector " [\fIlabel\fR=]\fIsel\fR"
@@ -158,11 +159,13 @@ Print the matched node's text content, whitespace collapsed.
158
159
  .TP
159
160
  .B \-\-json
160
161
  Print one JSON object per match (NDJSON) with
161
- .IR file ", " line ", " col ", " html ", " text
162
+ .IR file ", " line ", " col ", " attribs ", " html ", " text
162
163
  (plus
163
164
  .I label
164
165
  with
165
166
  .BR \-e ).
167
+ .I attribs
168
+ is the element's attribute object, names lowercased by the parser.
166
169
  .TP
167
170
  .BI \-\-parent " n"
168
171
  Report the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.3.0",
3
+ "version": "1.5.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",
@@ -37,7 +37,8 @@
37
37
  "build:darwin-arm64": "bun build ./cli.js --compile --target=bun-darwin-arm64 --outfile dist/cssgrep-darwin-arm64",
38
38
  "build:windows-x64": "bun build ./cli.js --compile --target=bun-windows-x64 --outfile dist/cssgrep-windows-x64.exe",
39
39
  "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",
40
- "build:sea": "node scripts/build-sea.js"
40
+ "build:sea": "node scripts/build-sea.js",
41
+ "bench": "node bench/bench.js"
41
42
  },
42
43
  "keywords": [
43
44
  "css",