cssgrep 1.4.0 → 1.6.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.0] - 2026-07-12
11
+
12
+ ### Added
13
+ - Embedded HTML in JS/TS: files with `.js`/`.mjs`/`.cjs`/`.jsx`/`.ts`/`.mts`/
14
+ `.cts`/`.tsx` extensions are searched for HTML inside template literals
15
+ (tagged or not), with locators pointing into the host file — quickfix jumps
16
+ straight to the match inside `` html`…` ``. `${…}` holes match as
17
+ whitespace but display as the original source; each literal parses
18
+ independently, so an unclosed tag in one can't leak into the next. Under
19
+ `-r`, opt in with `--ext js,ts`. JSX is not extracted (it isn't HTML).
20
+
21
+ ## [1.5.0] - 2026-07-12
22
+
23
+ ### Added
24
+ - `--json` records now include `attribs`, the matched element's attribute
25
+ object (names lowercased by the parser) — the same field the library's
26
+ `Match` exposes, so scraping pipelines get attribute data without a second
27
+ pass (e.g. `jq -r .attribs.href`).
28
+
10
29
  ## [1.4.0] - 2026-07-07
11
30
 
12
31
  ### Fixed
@@ -125,7 +144,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
125
144
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
126
145
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
127
146
 
128
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...HEAD
147
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.6.0...HEAD
148
+ [1.6.0]: https://github.com/msbatarce/cssgrep/compare/v1.5.0...v1.6.0
149
+ [1.5.0]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...v1.5.0
129
150
  [1.4.0]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...v1.4.0
130
151
  [1.3.0]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...v1.3.0
131
152
  [1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
package/README.md CHANGED
@@ -90,7 +90,7 @@ own `line:col` locator (that per-match precision is the point of the tool). A
90
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
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`. |
92
92
  | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
93
- | `--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. |
94
94
  | `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
95
95
  | `-w`, `--max-width <n>` | Truncate the shown line to `n` columns (adds `…`). Value attaches or follows: `-w100`, `-w 100`, `--max-width=100`. |
96
96
  | `-A`, `--after-context <n>` | Print `n` source lines after each match. |
@@ -187,8 +187,8 @@ $ cssgrep -n -e 'title=h1' -e 'price=.card .price' page.html
187
187
  9:12 [price] <span class="price">$4.99</span>
188
188
 
189
189
  $ cssgrep --json -e 'title=h1' -e 'price=.card .price' page.html
190
- {"file":"page.html","line":3,"col":5,"label":"title","html":"<h1>Widget</h1>","text":"Widget"}
191
- {"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"}
192
192
  ```
193
193
 
194
194
  The label is anything matching `[A-Za-z_][A-Za-z0-9_-]*` before a `=`; since a
@@ -204,6 +204,38 @@ globally to every selector. With `-e`, positional arguments are always file
204
204
  paths — like `grep -e`, a mistyped path is reported as unreadable rather than
205
205
  re-guessed as a selector.
206
206
 
207
+ ### Embedded HTML in JS/TS
208
+
209
+ Modern HTML often lives inside JavaScript. Files with JS/TS extensions
210
+ (`.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.mts`, `.cts`, `.tsx`) are
211
+ automatically searched for HTML inside **template literals** — lit-html's
212
+ `` html`…` ``, vanilla `` el.innerHTML = `…` ``, nested templates — and
213
+ matches report **host-file locators** that vim's quickfix jumps straight to:
214
+
215
+ ```sh
216
+ $ cssgrep '.price' -n components/card.js
217
+ 41:23 <span class="price">${item.price}</span>
218
+
219
+ $ cssgrep 'img:not([alt])' -rn --ext js,ts,html src/ # audit across both worlds
220
+ ```
221
+
222
+ How it works: every backtick literal whose content looks like markup (`<` +
223
+ letter) is parsed as its own document, with each `${…}` interpolation masked
224
+ to same-length whitespace — so offsets in the fragment *are* offsets in the
225
+ host file, and no position math can drift. Because fragments parse
226
+ independently, an unclosed tag in one literal can never swallow the next.
227
+ Printed lines and `--json`'s `html` field show the *original* source
228
+ (interpolations visible); selector matching, `attribs` and `--text` see holes
229
+ as whitespace, so `class="${cls} card"` matches `.card`, and a tag name
230
+ that's a hole (`<${Tag}>`) never matches. JSX is not extracted — it isn't
231
+ HTML.
232
+
233
+ With `-r`, JS/TS files are reached by adding their extensions (`--ext
234
+ js,ts`); the default stays `html,htm`. Server-side template files (PHP, ERB,
235
+ Handlebars…) usually need none of this — they're HTML with interruptions, and
236
+ forgiving parsing handles them: `cssgrep '.card' -r --ext php,erb .`. The
237
+ rewrite ops don't apply inside JS/TS files.
238
+
207
239
  ### Watch mode (`--watch`)
208
240
 
209
241
  `--watch` keeps the search running and re-runs it whenever a watched file
package/cli.js CHANGED
@@ -4,9 +4,15 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const {
7
- parse, offsetToPosition, lineTextAt, textOf, collapseWs, ancestor,
7
+ parse, extractHtmlFragments, lineIndex, offsetToPosition, lineTextAt,
8
+ textOf, collapseWs, ancestor,
8
9
  } = require('./lib.js');
9
10
 
11
+ // Files with these extensions are scanned for HTML inside JS/TS template
12
+ // literals instead of being parsed as one HTML document (ROADMAP Phase 12).
13
+ const EMBEDDED_EXTS = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx']);
14
+ const isEmbeddedPath = p => EMBEDDED_EXTS.has(path.extname(p).slice(1).toLowerCase());
15
+
10
16
  // dom-serializer + js-beautify are only needed by -p, and cost ~13 ms of a
11
17
  // ~43 ms startup (measured, ROADMAP Phase 11) — loaded on first use so the
12
18
  // grepprg-style hot path never pays for them.
@@ -23,7 +29,7 @@ function loadPrettyPrinter() {
23
29
  // package.json, so it survives compilation into a standalone binary (Bun
24
30
  // --compile / Node SEA), where package.json won't sit next to the executable.
25
31
  // Keep in sync with package.json on release.
26
- const VERSION = '1.4.0';
32
+ const VERSION = '1.6.0';
27
33
 
28
34
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
29
35
 
@@ -58,7 +64,7 @@ Options:
58
64
  --attr <name> Print the value of attribute <name> (skips nodes without it).
59
65
  --text Print the matched node's text content (whitespace collapsed).
60
66
  --json Print one JSON record per match (NDJSON: file,line,col,
61
- html,text; plus label with -e).
67
+ attribs,html,text; plus label with -e).
62
68
  --parent <n> Report the n-th ancestor of each match instead (dedup'd).
63
69
  -w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
64
70
  -A, --after-context <n> Print <n> source lines after each match.
@@ -109,6 +115,11 @@ value with = or as the next word (--max-width=100, --ext htm).
109
115
  Globs (--include/--ignore/--exclude) support *, ** (crosses /), ?, and brace
110
116
  alternation like *.{html,htm}.
111
117
 
118
+ JS/TS files (.js .mjs .cjs .jsx .ts .mts .cts .tsx) are searched for HTML
119
+ inside template literals: each markup-looking \`...\` is parsed on its own,
120
+ \${...} holes match as whitespace, and locators point into the host file
121
+ (with -r, add the extensions via --ext js,ts). Rewrite ops don't apply there.
122
+
112
123
  Exit status: 0 if any match was found, 1 if none, 2 on error.`;
113
124
 
114
125
  function fail(msg) {
@@ -541,11 +552,11 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
541
552
  // which drives the in-line highlight — and the [label] tag — when emitting.
542
553
  const info = new Map();
543
554
  const posState = {};
544
- for (const { el, label: selLabel } of targets) {
545
- const off = el.startIndex == null ? 0 : el.startIndex;
555
+ for (const { el, base, label: selLabel } of targets) {
556
+ const off = base + (el.startIndex == null ? 0 : el.startIndex);
546
557
  const pos = offsetToPosition(starts, src, off, posState);
547
558
  if (info.has(pos.line)) continue;
548
- const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
559
+ const nodeEnd = base + (el.endIndex == null ? off - base : el.endIndex) + 1;
549
560
  info.set(pos.line, { off, nodeEnd, pos, selLabel });
550
561
  }
551
562
 
@@ -585,13 +596,20 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
585
596
  // `name` is the source's display name (file path, or "(standard input)"); it is
586
597
  // used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
587
598
  // lines carry a `file:` prefix (only when more than one file is searched).
588
- function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
599
+ function searchSource(src, name, showLabel, opts, out, limit = Infinity, embedded = false) {
589
600
  // The lib owns parsing and selection: one parse per source, then one
590
601
  // doc.search() per selector against the same tree. The CLI works on the
591
602
  // raw nodes (the match objects' documented escape hatch) because its
592
603
  // output modes need node-level access the match shape doesn't model
593
604
  // (pretty-printing, ancestor re-targeting, in-line highlight spans).
594
- const doc = parse(src);
605
+ //
606
+ // A plain HTML file is one fragment covering the whole source (base 0).
607
+ // An embedded (JS/TS) file contributes one fragment per markup-sniffing
608
+ // template literal — holes masked to same-length whitespace, so fragment
609
+ // offsets + base ARE host-file offsets — each parsed as its own document,
610
+ // so an unclosed tag in one literal can never swallow the next.
611
+ const docs = (embedded ? extractHtmlFragments(src) : [{ start: 0, masked: src }])
612
+ .map(f => ({ doc: parse(f.masked), base: f.start }));
595
613
  // One record per (selector, matched node): with -e a node matched by two
596
614
  // selectors is reported once per selector, tagged with each label, and the
597
615
  // merged stream is in document order (same node = same offset, so the
@@ -603,10 +621,12 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
603
621
  : [{ label: null, selector: opts.selector }];
604
622
  const records = [];
605
623
  for (const s of selList) {
606
- for (const m of doc.search(s.selector)) records.push({ el: m.node, label: s.label });
624
+ for (const { doc, base } of docs) {
625
+ for (const m of doc.search(s.selector)) records.push({ el: m.node, base, label: s.label });
626
+ }
607
627
  }
608
- if (selList.length > 1) {
609
- records.sort((a, b) => (a.el.startIndex || 0) - (b.el.startIndex || 0));
628
+ if (selList.length > 1 || docs.length > 1) {
629
+ records.sort((a, b) => (a.base + (a.el.startIndex || 0)) - (b.base + (b.el.startIndex || 0)));
610
630
  }
611
631
  const found = records.length;
612
632
  // Aggregate modes suppress per-match output entirely.
@@ -630,11 +650,15 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
630
650
  // Nothing to emit: return before building the line index — a zero-match
631
651
  // pass over a large file shouldn't pay a full line scan for nothing.
632
652
  if (limited.length === 0) return 0;
633
- const starts = opts.print ? null : doc.lineStarts();
634
- // --parent re-targets matches to ancestors (no-op without it). Dedup is per
635
- // (ancestor, label), so two -e selectors sharing a container still report it
636
- // once each. Aggregate modes above operate on the raw matches; targeting
637
- // only affects what prints.
653
+ // Positions are host-file positions: fragments preserve line structure, so
654
+ // the host source's own line index serves embedded matches directly.
655
+ const starts = opts.print ? null
656
+ : embedded ? lineIndex(src)
657
+ : docs[0].doc.lineStarts();
658
+ // --parent re-targets matches to ancestors (no-op without it; never leaves
659
+ // the match's own fragment). Dedup is per (ancestor, label), so two -e
660
+ // selectors sharing a container still report it once each. Aggregate modes
661
+ // above operate on the raw matches; targeting only affects what prints.
638
662
  let targets = limited;
639
663
  if (opts.parent) {
640
664
  targets = [];
@@ -645,7 +669,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
645
669
  if (!labels) { labels = new Set(); seen.set(a, labels); }
646
670
  if (!labels.has(r.label)) {
647
671
  labels.add(r.label);
648
- targets.push({ el: a, label: r.label });
672
+ targets.push({ el: a, base: r.base, label: r.label });
649
673
  }
650
674
  }
651
675
  }
@@ -668,20 +692,22 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
668
692
  // matches lacking the attribute, and --parent dedup can merge several
669
693
  // matches into one printed ancestor.
670
694
  if (opts.json) {
671
- // NDJSON: one self-contained record per match. `html` is the exact source
672
- // slice; newlines are escaped by JSON.stringify, so each record stays on
673
- // one line. Ignores --color and -n (line/col are always present). `label`
674
- // appears only with -e.
695
+ // NDJSON: one self-contained record per match. `attribs` mirrors the lib
696
+ // Match's field (names lowercased by the parser) so scraping never needs
697
+ // a second pass; `html` is the exact source slice; newlines are escaped
698
+ // by JSON.stringify, so each record stays on one line. Ignores --color
699
+ // and -n (line/col are always present). `label` appears only with -e.
675
700
  const posState = {};
676
- for (const { el, label: selLabel } of targets) {
677
- const off = el.startIndex == null ? 0 : el.startIndex;
701
+ for (const { el, base, label: selLabel } of targets) {
702
+ const off = base + (el.startIndex == null ? 0 : el.startIndex);
678
703
  const pos = offsetToPosition(starts, src, off, posState);
679
- const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
704
+ const nodeEnd = base + (el.endIndex == null ? off - base : el.endIndex) + 1;
680
705
  out.push(JSON.stringify({
681
706
  file: name,
682
707
  line: pos.line,
683
708
  col: pos.bcol,
684
709
  ...(selLabel !== null && { label: selLabel }),
710
+ attribs: el.attribs || {},
685
711
  html: src.slice(off, nodeEnd),
686
712
  text: collapseWs(textOf(el)),
687
713
  }));
@@ -695,7 +721,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
695
721
  // Extraction modes print per-match values, so they never dedup.
696
722
  const seenLines = (opts.lineNumber || opts.attr != null || opts.text) ? null : new Set();
697
723
  const posState = {};
698
- for (const { el, label: selLabel } of targets) {
724
+ for (const { el, base, label: selLabel } of targets) {
699
725
  const c = opts.colorOn ? paint : (_, s) => s;
700
726
  // The [label] tag from -e; a null label (positional selector) prints none.
701
727
  const tag = selLabel === null ? '' : c(COLORS.label, `[${selLabel}]`) + ' ';
@@ -708,7 +734,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
708
734
  emitted++;
709
735
  continue;
710
736
  }
711
- const off = el.startIndex == null ? 0 : el.startIndex;
737
+ const off = base + (el.startIndex == null ? 0 : el.startIndex);
712
738
  const pos = offsetToPosition(starts, src, off, posState);
713
739
 
714
740
  // Choose the content printed for this match. --attr/--text replace the
@@ -725,7 +751,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
725
751
  if (seenLines.has(pos.line)) continue; // this line already printed
726
752
  seenLines.add(pos.line);
727
753
  }
728
- const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1; // exclusive
754
+ const nodeEnd = base + (el.endIndex == null ? off - base : el.endIndex) + 1; // exclusive
729
755
  text = renderText(pos, off, nodeEnd, opts);
730
756
  }
731
757
  // grep-style: a `file:` prefix appears with multiple files; the line:col
@@ -1194,7 +1220,7 @@ function searchFiles(opts, files, out) {
1194
1220
  }
1195
1221
  continue;
1196
1222
  }
1197
- total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
1223
+ total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room(), isEmbeddedPath(f));
1198
1224
  if (opts.quiet && total > 0) break; // -q: first match decides the status
1199
1225
  }
1200
1226
  return total;
package/lib.js CHANGED
@@ -115,6 +115,120 @@ function retarget(nodes, opts) {
115
115
  return result;
116
116
  }
117
117
 
118
+ // --- embedded HTML (JS/TS template literals) -----------------------------------
119
+
120
+ // Replace every char except line breaks with a space: masked text has the
121
+ // same length AND the same line structure as the original, so offsets and
122
+ // the host file's line index stay valid.
123
+ const maskWs = s => s.replace(/[^\n\r]/g, ' ');
124
+
125
+ // Skip a '...' or "..." string; `i` is at the opening quote. Returns the
126
+ // index just past the closing quote (or line end / EOF for unterminated).
127
+ function skipJsString(src, i) {
128
+ const quote = src[i++];
129
+ while (i < src.length) {
130
+ const c = src[i];
131
+ if (c === '\\') { i += 2; continue; }
132
+ if (c === quote) return i + 1;
133
+ if (c === '\n') return i; // unterminated: resync at newline
134
+ i++;
135
+ }
136
+ return i;
137
+ }
138
+
139
+ // Skip code until the '}' that closes a template hole (depth-balanced),
140
+ // recursing into strings, comments and nested template literals — a nested
141
+ // literal contributes its own fragments. `i` is just past '${'.
142
+ function skipJsCode(src, i, fragments) {
143
+ let depth = 0;
144
+ while (i < src.length) {
145
+ const c = src[i];
146
+ if (c === "'" || c === '"') { i = skipJsString(src, i); continue; }
147
+ if (c === '`') { i = readTemplate(src, i, fragments); continue; }
148
+ if (c === '/' && src[i + 1] === '/') {
149
+ const nl = src.indexOf('\n', i);
150
+ if (nl === -1) return src.length;
151
+ i = nl + 1;
152
+ continue;
153
+ }
154
+ if (c === '/' && src[i + 1] === '*') {
155
+ const end = src.indexOf('*/', i + 2);
156
+ if (end === -1) return src.length;
157
+ i = end + 2;
158
+ continue;
159
+ }
160
+ if (c === '{') depth++;
161
+ else if (c === '}') {
162
+ if (depth === 0) return i + 1;
163
+ depth--;
164
+ }
165
+ i++;
166
+ }
167
+ return i;
168
+ }
169
+
170
+ // Read one template literal; `i` is at the opening backtick. Masks every
171
+ // `${…}` hole to same-length whitespace and, when the masked content sniffs
172
+ // as markup (`<` + letter, or `<!`), records it as a fragment. Returns the
173
+ // index just past the closing backtick. Nested literals inside holes are
174
+ // processed first, so their fragments are collected too.
175
+ function readTemplate(src, i, fragments) {
176
+ const contentStart = ++i;
177
+ const holes = [];
178
+ while (i < src.length) {
179
+ const c = src[i];
180
+ if (c === '\\') { i += 2; continue; }
181
+ if (c === '`') break;
182
+ if (c === '$' && src[i + 1] === '{') {
183
+ const holeStart = i;
184
+ i = skipJsCode(src, i + 2, fragments);
185
+ holes.push([holeStart, i]);
186
+ continue;
187
+ }
188
+ i++;
189
+ }
190
+ const contentEnd = Math.min(i, src.length);
191
+ let masked = src.slice(contentStart, contentEnd);
192
+ for (const [hs, he] of holes) {
193
+ const a = hs - contentStart;
194
+ const b = Math.min(he, contentEnd) - contentStart;
195
+ masked = masked.slice(0, a) + maskWs(masked.slice(a, b)) + masked.slice(b);
196
+ }
197
+ if (/<[a-zA-Z!]/.test(masked)) fragments.push({ start: contentStart, masked });
198
+ return contentEnd + 1;
199
+ }
200
+
201
+ // Scan JS/TS source for template literals whose content looks like markup.
202
+ // Returns fragments of { start, masked }: `start` is the host-file offset of
203
+ // the literal's content, `masked` its text with `${…}` holes blanked to
204
+ // same-length whitespace — so every fragment offset IS a host offset.
205
+ // String, template and comment contexts are tracked; regex literals are not
206
+ // (a backtick inside a regex could mislead the scan — rare, and a mislead
207
+ // degrades to "no fragment", never to wrong positions of what is found).
208
+ function extractHtmlFragments(src) {
209
+ const fragments = [];
210
+ let i = 0;
211
+ while (i < src.length) {
212
+ const c = src[i];
213
+ if (c === "'" || c === '"') { i = skipJsString(src, i); continue; }
214
+ if (c === '`') { i = readTemplate(src, i, fragments); continue; }
215
+ if (c === '/' && src[i + 1] === '/') {
216
+ const nl = src.indexOf('\n', i);
217
+ if (nl === -1) break;
218
+ i = nl + 1;
219
+ continue;
220
+ }
221
+ if (c === '/' && src[i + 1] === '*') {
222
+ const end = src.indexOf('*/', i + 2);
223
+ if (end === -1) break;
224
+ i = end + 2;
225
+ continue;
226
+ }
227
+ i++;
228
+ }
229
+ return fragments;
230
+ }
231
+
118
232
  // --- rewrite machinery --------------------------------------------------------
119
233
 
120
234
  // Lex one opening tag starting at `start` (which must point at its `<`).
@@ -408,6 +522,7 @@ function parse(html) {
408
522
  module.exports = {
409
523
  parse,
410
524
  // Internal helpers, shared with cli.js; not part of the stable API.
525
+ extractHtmlFragments,
411
526
  lineIndex,
412
527
  offsetToPosition,
413
528
  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-07" "cssgrep 1.4.0" "User Commands"
3
+ .TH CSSGREP 1 "2026-07-12" "cssgrep 1.6.0" "User Commands"
4
4
  .SH NAME
5
5
  cssgrep \- search HTML by CSS selector, grep-style
6
6
  .SH SYNOPSIS
@@ -159,11 +159,13 @@ Print the matched node's text content, whitespace collapsed.
159
159
  .TP
160
160
  .B \-\-json
161
161
  Print one JSON object per match (NDJSON) with
162
- .IR file ", " line ", " col ", " html ", " text
162
+ .IR file ", " line ", " col ", " attribs ", " html ", " text
163
163
  (plus
164
164
  .I label
165
165
  with
166
166
  .BR \-e ).
167
+ .I attribs
168
+ is the element's attribute object, names lowercased by the parser.
167
169
  .TP
168
170
  .BI \-\-parent " n"
169
171
  Report the
@@ -269,6 +271,28 @@ Globs for
269
271
  support
270
272
  .BR * " (within a path segment), " ** " (across " / ),
271
273
  .BR ? " (one non-slash char), and brace alternation like " *.{html,htm} .
274
+ .SH EMBEDDED HTML
275
+ Files with JS/TS extensions
276
+ .RB ( .js ", " .mjs ", " .cjs ", " .jsx ", " .ts ", " .mts ", " .cts ", " .tsx )
277
+ are searched for HTML inside template literals: every backtick literal whose
278
+ content looks like markup is parsed as its own document, and each
279
+ .RI \(dq${ expr }\(dq
280
+ interpolation is masked to same-length whitespace, so match locators point
281
+ directly into the host file. Printed lines and
282
+ .BR \-\-json 's
283
+ .I html
284
+ field show the original source with interpolations visible; selector
285
+ matching sees holes as whitespace (so
286
+ .I class=\(dq${cls} card\(dq
287
+ matches
288
+ .IR .card ).
289
+ Fragments parse independently \(em an unclosed tag in one literal never
290
+ swallows the next. With
291
+ .BR \-r ,
292
+ add the extensions via
293
+ .BR "\-\-ext js,ts" .
294
+ JSX is not extracted (it is not HTML), and the rewrite operations treat these
295
+ files as plain HTML.
272
296
  .SH WATCH MODE
273
297
  .TP
274
298
  .B \-\-watch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",