cssgrep 1.5.0 → 1.7.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.7.0] - 2026-07-12
11
+
12
+ ### Added
13
+ - `-p` output is syntax-highlighted when color is on: tag names, attribute
14
+ names, attribute values and comments each get a color, and with
15
+ `--parent -p` the matched node's region stays wrapped in unbroken match
16
+ red on top of it. `--color=never` (or piping) keeps the plain output.
17
+
18
+ ## [1.6.0] - 2026-07-12
19
+
20
+ ### Added
21
+ - Embedded HTML in JS/TS: files with `.js`/`.mjs`/`.cjs`/`.jsx`/`.ts`/`.mts`/
22
+ `.cts`/`.tsx` extensions are searched for HTML inside template literals
23
+ (tagged or not), with locators pointing into the host file — quickfix jumps
24
+ straight to the match inside `` html`…` ``. `${…}` holes match as
25
+ whitespace but display as the original source; each literal parses
26
+ independently, so an unclosed tag in one can't leak into the next. Under
27
+ `-r`, opt in with `--ext js,ts`. JSX is not extracted (it isn't HTML).
28
+
10
29
  ## [1.5.0] - 2026-07-12
11
30
 
12
31
  ### Added
@@ -133,7 +152,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
133
152
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
134
153
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
135
154
 
136
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.5.0...HEAD
155
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.7.0...HEAD
156
+ [1.7.0]: https://github.com/msbatarce/cssgrep/compare/v1.6.0...v1.7.0
157
+ [1.6.0]: https://github.com/msbatarce/cssgrep/compare/v1.5.0...v1.6.0
137
158
  [1.5.0]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...v1.5.0
138
159
  [1.4.0]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...v1.4.0
139
160
  [1.3.0]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...v1.3.0
package/README.md CHANGED
@@ -87,7 +87,7 @@ own `line:col` locator (that per-match precision is the point of the tool). A
87
87
  | `--ignore-file <path>` | Load ignore globs from a file, one per line (`#` comments and blank lines ignored) — like a `.gitignore`. |
88
88
  | `-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
89
  | `-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. |
90
+ | `-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
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
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. |
@@ -132,9 +132,11 @@ useful. Suppress the note with `-s` (or `-q`).
132
132
 
133
133
  When coloring is on, the matched node is highlighted within its line (grep's
134
134
  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.
135
+ (magenta and green, like grep). `-p` blocks are syntax-highlighted tag
136
+ names, attribute names, attribute values and comments each get a color and
137
+ with `--parent <n>` the original matched node is additionally wrapped in the
138
+ match color inside the printed container (match red wins over syntax within
139
+ its region), so you can see what matched within its surroundings.
138
140
 
139
141
  Exit status: `0` if any match was found, `1` if none, `2` on error — same
140
142
  convention as `grep`.
@@ -204,6 +206,38 @@ globally to every selector. With `-e`, positional arguments are always file
204
206
  paths — like `grep -e`, a mistyped path is reported as unreadable rather than
205
207
  re-guessed as a selector.
206
208
 
209
+ ### Embedded HTML in JS/TS
210
+
211
+ Modern HTML often lives inside JavaScript. Files with JS/TS extensions
212
+ (`.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.mts`, `.cts`, `.tsx`) are
213
+ automatically searched for HTML inside **template literals** — lit-html's
214
+ `` html`…` ``, vanilla `` el.innerHTML = `…` ``, nested templates — and
215
+ matches report **host-file locators** that vim's quickfix jumps straight to:
216
+
217
+ ```sh
218
+ $ cssgrep '.price' -n components/card.js
219
+ 41:23 <span class="price">${item.price}</span>
220
+
221
+ $ cssgrep 'img:not([alt])' -rn --ext js,ts,html src/ # audit across both worlds
222
+ ```
223
+
224
+ How it works: every backtick literal whose content looks like markup (`<` +
225
+ letter) is parsed as its own document, with each `${…}` interpolation masked
226
+ to same-length whitespace — so offsets in the fragment *are* offsets in the
227
+ host file, and no position math can drift. Because fragments parse
228
+ independently, an unclosed tag in one literal can never swallow the next.
229
+ Printed lines and `--json`'s `html` field show the *original* source
230
+ (interpolations visible); selector matching, `attribs` and `--text` see holes
231
+ as whitespace, so `class="${cls} card"` matches `.card`, and a tag name
232
+ that's a hole (`<${Tag}>`) never matches. JSX is not extracted — it isn't
233
+ HTML.
234
+
235
+ With `-r`, JS/TS files are reached by adding their extensions (`--ext
236
+ js,ts`); the default stays `html,htm`. Server-side template files (PHP, ERB,
237
+ Handlebars…) usually need none of this — they're HTML with interruptions, and
238
+ forgiving parsing handles them: `cssgrep '.card' -r --ext php,erb .`. The
239
+ rewrite ops don't apply inside JS/TS files.
240
+
207
241
  ### Watch mode (`--watch`)
208
242
 
209
243
  `--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, lexOpenTag, lineIndex, offsetToPosition,
8
+ lineTextAt, 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.5.0';
32
+ const VERSION = '1.7.0';
27
33
 
28
34
  const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
29
35
 
@@ -54,7 +60,8 @@ Options:
54
60
  -S, --follow Follow symbolic links when recursing with -r
55
61
  (default: skip them; loops are detected).
56
62
  -n, --line-number Prefix each match with its line:col (excludes -c, -p).
57
- -p, --print Pretty-print the matched node's HTML above its location.
63
+ -p, --print Pretty-print the matched node's HTML, syntax-
64
+ highlighted when color is on.
58
65
  --attr <name> Print the value of attribute <name> (skips nodes without it).
59
66
  --text Print the matched node's text content (whitespace collapsed).
60
67
  --json Print one JSON record per match (NDJSON: file,line,col,
@@ -109,6 +116,11 @@ value with = or as the next word (--max-width=100, --ext htm).
109
116
  Globs (--include/--ignore/--exclude) support *, ** (crosses /), ?, and brace
110
117
  alternation like *.{html,htm}.
111
118
 
119
+ JS/TS files (.js .mjs .cjs .jsx .ts .mts .cts .tsx) are searched for HTML
120
+ inside template literals: each markup-looking \`...\` is parsed on its own,
121
+ \${...} holes match as whitespace, and locators point into the host file
122
+ (with -r, add the extensions via --ext js,ts). Rewrite ops don't apply there.
123
+
112
124
  Exit status: 0 if any match was found, 1 if none, 2 on error.`;
113
125
 
114
126
  function fail(msg) {
@@ -126,12 +138,19 @@ function failInvert(flag) {
126
138
  // ANSI SGR codes matching grep's default scheme: bold-red match, magenta
127
139
  // filename, green line/col numbers, cyan separators. The [label] tag from -e
128
140
  // has no grep counterpart; yellow keeps it distinct from all of the above.
141
+ // The last four paint -p's syntax highlighting (tag names, attribute names,
142
+ // attribute values, comments/doctypes) — distinct from match red, which
143
+ // always wins inside a --parent -p match region.
129
144
  const COLORS = {
130
145
  match: '1;31',
131
146
  file: '35',
132
147
  line: '32',
133
148
  sep: '36',
134
149
  label: '33',
150
+ tag: '1;34',
151
+ attr: '36',
152
+ value: '32',
153
+ comment: '90',
135
154
  };
136
155
 
137
156
  function paint(code, str) {
@@ -375,9 +394,8 @@ function parseArgs(argv) {
375
394
  fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
376
395
  }
377
396
  // Resolve the tri-state into a single boolean: color only when forced on, or
378
- // 'auto' and stdout is an interactive terminal. Plain -p still prints no
379
- // color (it has nothing to highlight) that's gated where it prints, not
380
- // here, so --parent -p can highlight the matched node inside the container.
397
+ // 'auto' and stdout is an interactive terminal. -p uses it for syntax
398
+ // highlighting (and --parent -p for the match region on top of it).
381
399
  opts.colorOn = opts.color === 'always' || (opts.color === 'auto' && Boolean(process.stdout.isTTY));
382
400
  return opts;
383
401
  }
@@ -456,14 +474,63 @@ function renderText(pos, off, nodeEnd, opts) {
456
474
  const HL_START = '';
457
475
  const HL_END = '';
458
476
 
477
+ // Syntax-highlight a pretty-printed HTML block: tag names, attribute names,
478
+ // attribute values (quotes included) and comments/doctypes get their own
479
+ // colors; text content and punctuation stay unpainted. Spans come from the
480
+ // same opening-tag lexer the rewrite mode uses, so a quoted '>' never
481
+ // confuses the scan.
482
+ function highlightHtml(s) {
483
+ let out = '';
484
+ let i = 0;
485
+ while (i < s.length) {
486
+ const ch = s[i];
487
+ if (ch === '<' && (s.startsWith('<!--', i) || s[i + 1] === '!')) {
488
+ const close = s.startsWith('<!--', i) ? '-->' : '>';
489
+ let end = s.indexOf(close, i + 2);
490
+ end = end === -1 ? s.length : end + close.length;
491
+ out += paint(COLORS.comment, s.slice(i, end));
492
+ i = end;
493
+ continue;
494
+ }
495
+ if (ch === '<' && s[i + 1] === '/') {
496
+ const m = /^<\/([^\s>]*)([^>]*>?)/.exec(s.slice(i));
497
+ out += '</' + paint(COLORS.tag, m[1]) + m[2];
498
+ i += m[0].length;
499
+ continue;
500
+ }
501
+ if (ch === '<' && /[a-zA-Z]/.test(s[i + 1] || '')) {
502
+ const lx = lexOpenTag(s, i);
503
+ out += '<' + paint(COLORS.tag, s.slice(lx.nameStart, lx.nameEnd));
504
+ let j = lx.nameEnd;
505
+ for (const a of lx.attrs) {
506
+ out += s.slice(j, a.start) + paint(COLORS.attr, s.slice(a.start, a.nameEnd));
507
+ if (a.vStart >= 0) {
508
+ const quote = s[a.vStart - 1];
509
+ const valFrom = quote === '"' || quote === "'" ? a.vStart - 1 : a.vStart;
510
+ out += s.slice(a.nameEnd, valFrom) + paint(COLORS.value, s.slice(valFrom, a.end));
511
+ }
512
+ j = a.end;
513
+ }
514
+ out += s.slice(j, lx.end);
515
+ i = lx.end;
516
+ continue;
517
+ }
518
+ out += ch;
519
+ i++;
520
+ }
521
+ return out;
522
+ }
523
+
459
524
  // Re-indent a matched node's HTML from scratch (so minified input still comes
460
525
  // out readable). dom-serializer turns the parsed node back into a string;
461
- // js-beautify does the formatting. When `origins` (descendant nodes to
462
- // highlight) is given and coloring is on, those nodes are wrapped in the match
463
- // color within the printed block.
526
+ // js-beautify does the formatting. With color on the block is syntax-
527
+ // highlighted; when `origins` (descendant nodes to highlight) is also given,
528
+ // those nodes are wrapped in the match color instead — match red wins over
529
+ // syntax inside its region.
464
530
  function prettyPrint(el, origins, opts) {
465
531
  loadPrettyPrinter();
466
- const highlight = origins && origins.length && opts && opts.colorOn;
532
+ const colorOn = Boolean(opts && opts.colorOn);
533
+ const highlight = origins && origins.length && colorOn;
467
534
  const inserted = [];
468
535
  if (highlight) {
469
536
  // Bracket each origin with sentinel comment nodes among its siblings.
@@ -493,13 +560,23 @@ function prettyPrint(el, origins, opts) {
493
560
  k = kids.indexOf(end); if (k >= 0) kids.splice(k, 1);
494
561
  }
495
562
  }
496
- if (!highlight) return html;
563
+ if (!colorOn) return html;
564
+ if (!highlight) return highlightHtml(html);
565
+ // Split on the sentinel comments: regions outside the match get syntax
566
+ // highlighting, the match region itself gets the match color, unbroken —
567
+ // syntax codes inside would end the red at every token.
497
568
  const startCode = `\x1b[${COLORS.match}m`;
498
569
  const resetCode = '\x1b[0m';
499
- html = html
500
- .replace(new RegExp(`<!--\\s*${HL_START}\\s*-->`, 'g'), startCode)
501
- .replace(new RegExp(`<!--\\s*${HL_END}\\s*-->`, 'g'), resetCode);
502
- return foldStandaloneCodes(html, startCode, resetCode);
570
+ const isStart = new RegExp(`^<!--\\s*${HL_START}\\s*-->$`);
571
+ const isEnd = new RegExp(`^<!--\\s*${HL_END}\\s*-->$`);
572
+ let out = '';
573
+ let inside = false;
574
+ for (const part of html.split(new RegExp(`(<!--\\s*[${HL_START}${HL_END}]\\s*-->)`, 'g'))) {
575
+ if (isStart.test(part)) { out += startCode; inside = true; continue; }
576
+ if (isEnd.test(part)) { out += resetCode; inside = false; continue; }
577
+ out += inside ? part : highlightHtml(part);
578
+ }
579
+ return foldStandaloneCodes(out, startCode, resetCode);
503
580
  }
504
581
 
505
582
  // beautify sometimes parks a marker comment on its own line (e.g. when the
@@ -541,11 +618,11 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
541
618
  // which drives the in-line highlight — and the [label] tag — when emitting.
542
619
  const info = new Map();
543
620
  const posState = {};
544
- for (const { el, label: selLabel } of targets) {
545
- const off = el.startIndex == null ? 0 : el.startIndex;
621
+ for (const { el, base, label: selLabel } of targets) {
622
+ const off = base + (el.startIndex == null ? 0 : el.startIndex);
546
623
  const pos = offsetToPosition(starts, src, off, posState);
547
624
  if (info.has(pos.line)) continue;
548
- const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
625
+ const nodeEnd = base + (el.endIndex == null ? off - base : el.endIndex) + 1;
549
626
  info.set(pos.line, { off, nodeEnd, pos, selLabel });
550
627
  }
551
628
 
@@ -585,13 +662,20 @@ function emitContext(src, starts, name, showLabel, targets, opts, out) {
585
662
  // `name` is the source's display name (file path, or "(standard input)"); it is
586
663
  // used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
587
664
  // lines carry a `file:` prefix (only when more than one file is searched).
588
- function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
665
+ function searchSource(src, name, showLabel, opts, out, limit = Infinity, embedded = false) {
589
666
  // The lib owns parsing and selection: one parse per source, then one
590
667
  // doc.search() per selector against the same tree. The CLI works on the
591
668
  // raw nodes (the match objects' documented escape hatch) because its
592
669
  // output modes need node-level access the match shape doesn't model
593
670
  // (pretty-printing, ancestor re-targeting, in-line highlight spans).
594
- const doc = parse(src);
671
+ //
672
+ // A plain HTML file is one fragment covering the whole source (base 0).
673
+ // An embedded (JS/TS) file contributes one fragment per markup-sniffing
674
+ // template literal — holes masked to same-length whitespace, so fragment
675
+ // offsets + base ARE host-file offsets — each parsed as its own document,
676
+ // so an unclosed tag in one literal can never swallow the next.
677
+ const docs = (embedded ? extractHtmlFragments(src) : [{ start: 0, masked: src }])
678
+ .map(f => ({ doc: parse(f.masked), base: f.start }));
595
679
  // One record per (selector, matched node): with -e a node matched by two
596
680
  // selectors is reported once per selector, tagged with each label, and the
597
681
  // merged stream is in document order (same node = same offset, so the
@@ -603,10 +687,12 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
603
687
  : [{ label: null, selector: opts.selector }];
604
688
  const records = [];
605
689
  for (const s of selList) {
606
- for (const m of doc.search(s.selector)) records.push({ el: m.node, label: s.label });
690
+ for (const { doc, base } of docs) {
691
+ for (const m of doc.search(s.selector)) records.push({ el: m.node, base, label: s.label });
692
+ }
607
693
  }
608
- if (selList.length > 1) {
609
- records.sort((a, b) => (a.el.startIndex || 0) - (b.el.startIndex || 0));
694
+ if (selList.length > 1 || docs.length > 1) {
695
+ records.sort((a, b) => (a.base + (a.el.startIndex || 0)) - (b.base + (b.el.startIndex || 0)));
610
696
  }
611
697
  const found = records.length;
612
698
  // Aggregate modes suppress per-match output entirely.
@@ -630,11 +716,15 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
630
716
  // Nothing to emit: return before building the line index — a zero-match
631
717
  // pass over a large file shouldn't pay a full line scan for nothing.
632
718
  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.
719
+ // Positions are host-file positions: fragments preserve line structure, so
720
+ // the host source's own line index serves embedded matches directly.
721
+ const starts = opts.print ? null
722
+ : embedded ? lineIndex(src)
723
+ : docs[0].doc.lineStarts();
724
+ // --parent re-targets matches to ancestors (no-op without it; never leaves
725
+ // the match's own fragment). Dedup is per (ancestor, label), so two -e
726
+ // selectors sharing a container still report it once each. Aggregate modes
727
+ // above operate on the raw matches; targeting only affects what prints.
638
728
  let targets = limited;
639
729
  if (opts.parent) {
640
730
  targets = [];
@@ -645,7 +735,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
645
735
  if (!labels) { labels = new Set(); seen.set(a, labels); }
646
736
  if (!labels.has(r.label)) {
647
737
  labels.add(r.label);
648
- targets.push({ el: a, label: r.label });
738
+ targets.push({ el: a, base: r.base, label: r.label });
649
739
  }
650
740
  }
651
741
  }
@@ -674,10 +764,10 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
674
764
  // by JSON.stringify, so each record stays on one line. Ignores --color
675
765
  // and -n (line/col are always present). `label` appears only with -e.
676
766
  const posState = {};
677
- for (const { el, label: selLabel } of targets) {
678
- const off = el.startIndex == null ? 0 : el.startIndex;
767
+ for (const { el, base, label: selLabel } of targets) {
768
+ const off = base + (el.startIndex == null ? 0 : el.startIndex);
679
769
  const pos = offsetToPosition(starts, src, off, posState);
680
- const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
770
+ const nodeEnd = base + (el.endIndex == null ? off - base : el.endIndex) + 1;
681
771
  out.push(JSON.stringify({
682
772
  file: name,
683
773
  line: pos.line,
@@ -697,7 +787,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
697
787
  // Extraction modes print per-match values, so they never dedup.
698
788
  const seenLines = (opts.lineNumber || opts.attr != null || opts.text) ? null : new Set();
699
789
  const posState = {};
700
- for (const { el, label: selLabel } of targets) {
790
+ for (const { el, base, label: selLabel } of targets) {
701
791
  const c = opts.colorOn ? paint : (_, s) => s;
702
792
  // The [label] tag from -e; a null label (positional selector) prints none.
703
793
  const tag = selLabel === null ? '' : c(COLORS.label, `[${selLabel}]`) + ' ';
@@ -710,7 +800,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
710
800
  emitted++;
711
801
  continue;
712
802
  }
713
- const off = el.startIndex == null ? 0 : el.startIndex;
803
+ const off = base + (el.startIndex == null ? 0 : el.startIndex);
714
804
  const pos = offsetToPosition(starts, src, off, posState);
715
805
 
716
806
  // Choose the content printed for this match. --attr/--text replace the
@@ -727,7 +817,7 @@ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
727
817
  if (seenLines.has(pos.line)) continue; // this line already printed
728
818
  seenLines.add(pos.line);
729
819
  }
730
- const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1; // exclusive
820
+ const nodeEnd = base + (el.endIndex == null ? off - base : el.endIndex) + 1; // exclusive
731
821
  text = renderText(pos, off, nodeEnd, opts);
732
822
  }
733
823
  // grep-style: a `file:` prefix appears with multiple files; the line:col
@@ -1196,7 +1286,7 @@ function searchFiles(opts, files, out) {
1196
1286
  }
1197
1287
  continue;
1198
1288
  }
1199
- total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room());
1289
+ total += searchSource(buf.toString('utf8'), f, showLabel, opts, out, room(), isEmbeddedPath(f));
1200
1290
  if (opts.quiet && total > 0) break; // -q: first match decides the status
1201
1291
  }
1202
1292
  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,8 @@ 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,
526
+ lexOpenTag,
411
527
  lineIndex,
412
528
  offsetToPosition,
413
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.5.0" "User Commands"
3
+ .TH CSSGREP 1 "2026-07-12" "cssgrep 1.7.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
@@ -271,6 +273,28 @@ Globs for
271
273
  support
272
274
  .BR * " (within a path segment), " ** " (across " / ),
273
275
  .BR ? " (one non-slash char), and brace alternation like " *.{html,htm} .
276
+ .SH EMBEDDED HTML
277
+ Files with JS/TS extensions
278
+ .RB ( .js ", " .mjs ", " .cjs ", " .jsx ", " .ts ", " .mts ", " .cts ", " .tsx )
279
+ are searched for HTML inside template literals: every backtick literal whose
280
+ content looks like markup is parsed as its own document, and each
281
+ .RI \(dq${ expr }\(dq
282
+ interpolation is masked to same-length whitespace, so match locators point
283
+ directly into the host file. Printed lines and
284
+ .BR \-\-json 's
285
+ .I html
286
+ field show the original source with interpolations visible; selector
287
+ matching sees holes as whitespace (so
288
+ .I class=\(dq${cls} card\(dq
289
+ matches
290
+ .IR .card ).
291
+ Fragments parse independently \(em an unclosed tag in one literal never
292
+ swallows the next. With
293
+ .BR \-r ,
294
+ add the extensions via
295
+ .BR "\-\-ext js,ts" .
296
+ JSX is not extracted (it is not HTML), and the rewrite operations treat these
297
+ files as plain HTML.
274
298
  .SH WATCH MODE
275
299
  .TP
276
300
  .B \-\-watch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssgrep",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",