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 +22 -1
- package/README.md +38 -4
- package/cli.js +126 -36
- package/lib.js +116 -0
- package/man/cssgrep.1 +26 -2
- package/package.json +1 -1
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.
|
|
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).
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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.
|
|
379
|
-
//
|
|
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.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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.
|
|
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
|
|
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
|