mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 06:36:26 +03:00
fix(live-wrap): JSX/TSX correctness — multi-line tags, className, tag narrowing
Five related bugs that surfaced in a real Next.js App Router project
(EAC) all rooted in live-wrap.mjs treating source as line-anchored HTML:
1. findElement matched on raw substring anywhere, so it landed on a
className continuation line of a multi-line JSX tag whose class
happened to collide with a later target. The wrong tag got wrapped
(really, its attribute line got wrapped, producing broken JSX).
2. findClosingLine's opener regex required whitespace or `>` after the
tag name, so a bare `<section\n className="..."\n>` opener was
unrecognised; it returned `start` silently, capturing only one line.
3. buildSearchQueries only emitted `class="..."`, missing React's
`className="..."`. The full-combo query never fired in JSX, so
search silently degraded to single-class substring matching.
4. Wrapper output used `style="display: contents"` unconditionally,
which is invalid JSX (type error in strict setups, parser hazard
in production transforms).
5. --tag was ignored during the primary class search. Ambiguous class
hits inside the wrong element type weren't filtered out.
## Fixes
- New OPENER_RE `/<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/` recognises
tag openers at end-of-line too.
- New findOpenerLine(lines, matchLine, tag): walks up to 10 lines
backward to the enclosing opener when the match lands on a
continuation line. Aborts the walk if it hits a different tag.
- findElement now iterates all matches (not just the first), takes
a tag parameter, and routes through findOpenerLine; wrapCli passes
--tag through.
- buildSearchQueries emits both `class="..."` and `className="..."`
for multi-class queries, and both `<tag class="..."` /
`<tag className="..."` for tag+class combos.
- Wrapper builder emits `style={{ display: "contents" }}` when
commentSyntax is JSX and `style="display: contents"` otherwise.
- findClosingLine uses the same OPENER_RE so its tag-name extraction
works on multi-line openers too.
## Tests
Five new regression tests in tests/live-wrap.test.mjs, all failing
before the fix, all passing after:
- wraps the correct <section> when a class collides with a multi-line
tag elsewhere
- emits JSX-safe style attribute ({{ }}) in .tsx files
- finds elements via className= (React) when the exact class combo is
unique there
- respects --tag to reject matches inside the wrong element type
- findClosingLine recognises an opener line where the tag sits at
end-of-line (multi-line JSX)
31/31 in tests/live-wrap.test.mjs and 54/54 in
tests/framework-fixtures.test.mjs pass.
Credit: precise bug report from the other agent in the EAC session
made diagnosis and test design straightforward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
67e468f84c
commit
a4832adf2f
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -115,10 +115,12 @@ The agent should insert variant HTML at insertLine.`);
|
||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the element, trying each query in priority order
|
||||
// Find the element, trying each query in priority order.
|
||||
// Pass tag hint so findElement can reject matches inside wrong element types
|
||||
// and walk backward to the real opener on multi-line JSX tags.
|
||||
let match = null;
|
||||
for (const q of queries) {
|
||||
match = findElement(lines, q);
|
||||
match = findElement(lines, q, tag);
|
||||
if (match) break;
|
||||
}
|
||||
if (!match) {
|
||||
@@ -128,16 +130,22 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const { startLine, endLine } = match;
|
||||
const commentSyntax = detectCommentSyntax(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const indent = lines[startLine].match(/^(\s*)/)[1];
|
||||
|
||||
// Extract the original element
|
||||
const originalLines = lines.slice(startLine, endLine + 1);
|
||||
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
// either type-errors or renders a literal CSS string).
|
||||
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
|
||||
|
||||
// Build the wrapper
|
||||
const wrapperLines = [
|
||||
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" style="display: contents">',
|
||||
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
|
||||
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
|
||||
indent + ' <div data-impeccable-variant="original">',
|
||||
originalIndented,
|
||||
@@ -189,23 +197,28 @@ function buildSearchQueries(elementId, classes, tag, query) {
|
||||
queries.push('id="' + elementId + '"');
|
||||
}
|
||||
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos)
|
||||
// 2. Full class attribute match (for elements with distinctive multi-class combos).
|
||||
// Emit both class="..." (HTML) and className="..." (React/JSX) so whichever
|
||||
// convention the file uses will match.
|
||||
if (classes) {
|
||||
const classList = classes.split(',').map(c => c.trim()).filter(Boolean);
|
||||
if (classList.length > 1) {
|
||||
// Try the most distinctive class first (longest, most specific)
|
||||
const joined = classList.join(' ');
|
||||
const sorted = [...classList].sort((a, b) => b.length - a.length);
|
||||
queries.push('class="' + classList.join(' ') + '"'); // exact full match
|
||||
queries.push(sorted[0]); // most distinctive single class
|
||||
queries.push('class="' + joined + '"');
|
||||
queries.push('className="' + joined + '"');
|
||||
queries.push(sorted[0]); // most distinctive single class, fallback
|
||||
} else if (classList.length === 1) {
|
||||
queries.push(classList[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag + class combo (e.g., <section class="hero">)
|
||||
// 3. Tag + class combo (e.g., <section class="hero">).
|
||||
// Same dual-emit for JSX compatibility.
|
||||
if (tag && classes) {
|
||||
const firstClass = classes.split(',')[0].trim();
|
||||
queries.push('<' + tag + ' class="' + firstClass);
|
||||
queries.push('<' + tag + ' className="' + firstClass);
|
||||
}
|
||||
|
||||
// 4. Raw fallback query
|
||||
@@ -281,30 +294,66 @@ function searchDir(dir, query, seen, depth, genOpts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
* The query is a class name, ID, or text snippet.
|
||||
* We find the line containing the query, then find the matching closing tag.
|
||||
* Regex that matches a tag opener on a line. Allows the tag name to be
|
||||
* followed by whitespace, `>`, `/`, or end-of-line so that multi-line JSX
|
||||
* openers (e.g. `<section\n className="..."\n>`) are recognised.
|
||||
*/
|
||||
function findElement(lines, query) {
|
||||
// Find the line containing the query
|
||||
let startLine = -1;
|
||||
const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
|
||||
|
||||
/**
|
||||
* Find the element's start and end line in the file.
|
||||
*
|
||||
* `query` is a class name, attribute fragment (`class="..."`, `className="..."`,
|
||||
* `id="..."`), or a raw text snippet. Because a query can appear on a
|
||||
* continuation line of a multi-line tag (e.g. the `className="..."` row of a
|
||||
* `<section\n className="..."\n>` JSX tag), we walk backward from the match
|
||||
* line to find the actual tag opener. When `tag` is provided, opener candidates
|
||||
* must match that tag name.
|
||||
*/
|
||||
function findElement(lines, query, tag = null) {
|
||||
// Iterate all matches — the first substring hit isn't always the right one.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(query)) {
|
||||
// Make sure this looks like a tag opening, not a comment or string
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('<!--') || line.startsWith('{/*') || line.startsWith('//')) continue;
|
||||
// Skip lines inside data-impeccable-variant containers (already wrapped)
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
if (!lines[i].includes(query)) continue;
|
||||
|
||||
const stripped = lines[i].trim();
|
||||
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
|
||||
// Skip lines already inside a variant wrapper
|
||||
if (lines[i].includes('data-impeccable-variant')) continue;
|
||||
|
||||
const openerLine = findOpenerLine(lines, i, tag);
|
||||
if (openerLine === -1) continue;
|
||||
|
||||
const endLine = findClosingLine(lines, openerLine);
|
||||
return { startLine: openerLine, endLine };
|
||||
}
|
||||
|
||||
if (startLine === -1) return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the end of this element by counting open/close tags
|
||||
const endLine = findClosingLine(lines, startLine);
|
||||
return { startLine, endLine };
|
||||
/**
|
||||
* Resolve a match line to the real tag opener. If the match line itself opens
|
||||
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
|
||||
* first tag opener. If `tag` is specified, the opener must match that tag
|
||||
* name; an opener with a different tag name aborts the backward walk for this
|
||||
* match (we don't jump across element boundaries).
|
||||
*
|
||||
* Returns the line index of the opener, or -1 if none can be resolved.
|
||||
*/
|
||||
function findOpenerLine(lines, matchLine, tag) {
|
||||
const self = lines[matchLine].match(OPENER_RE);
|
||||
if (self) {
|
||||
if (!tag || self[1] === tag) return matchLine;
|
||||
return -1;
|
||||
}
|
||||
const MAX_BACKWALK = 10;
|
||||
for (let i = matchLine - 1; i >= Math.max(0, matchLine - MAX_BACKWALK); i--) {
|
||||
const opener = lines[i].match(OPENER_RE);
|
||||
if (!opener) continue;
|
||||
if (!tag || opener[1] === tag) return i;
|
||||
// Different tag name than requested — abort; we're inside a non-target opener.
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,19 +361,20 @@ function findElement(lines, query) {
|
||||
* closing tag by counting tag nesting depth.
|
||||
*/
|
||||
function findClosingLine(lines, start) {
|
||||
// Extract the tag name from the opening line
|
||||
const openMatch = lines[start].match(/<(\w+)[\s>]/);
|
||||
if (!openMatch) return start; // self-closing or text-only line
|
||||
const openMatch = lines[start].match(OPENER_RE);
|
||||
if (!openMatch) return start; // caller passed a non-opener; nothing to span
|
||||
|
||||
const tagName = openMatch[1];
|
||||
let depth = 0;
|
||||
const openRe = new RegExp('<' + tagName + '(?=[\\s/>]|$)', 'g');
|
||||
const selfCloseRe = new RegExp('<' + tagName + '[^>]*/>', 'g');
|
||||
const closeRe = new RegExp('</' + tagName + '\\s*>', 'g');
|
||||
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Count opening tags (not self-closing)
|
||||
const opens = (line.match(new RegExp('<' + tagName + '[\\s>]', 'g')) || []).length;
|
||||
const selfCloses = (line.match(new RegExp('<' + tagName + '[^>]*/>', 'g')) || []).length;
|
||||
const closes = (line.match(new RegExp('</' + tagName + '\\s*>', 'g')) || []).length;
|
||||
const opens = (line.match(openRe) || []).length;
|
||||
const selfCloses = (line.match(selfCloseRe) || []).length;
|
||||
const closes = (line.match(closeRe) || []).length;
|
||||
|
||||
depth += opens - selfCloses - closes;
|
||||
|
||||
|
||||
@@ -323,3 +323,159 @@ describe('wrapCli integration', () => {
|
||||
assert.ok(modified.includes('data-impeccable-variants="pres123"'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression tests from real-world failures (EAC report, 2026-04)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('live-wrap — JSX / TSX correctness', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-wrap-jsx-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('wraps the correct <section> when a class collides with a multi-line tag elsewhere', () => {
|
||||
// Decoy section: multi-line JSX with `organic-sand-surface` inside className
|
||||
// but NOT the full `py-20 lg:py-24` combo.
|
||||
// Target section: same class token on one line, together with py-20 lg:py-24.
|
||||
//
|
||||
// Bug: substring matcher lands on the decoy's className continuation line,
|
||||
// mangling the decoy tag and missing the real target entirely.
|
||||
const tsx = `export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<section
|
||||
className="organic-sand-surface public-arc-top-section relative z-10 pb-16 lg:pb-20"
|
||||
id="marketplace-intro"
|
||||
>
|
||||
<h2>Intro</h2>
|
||||
</section>
|
||||
|
||||
<section className="organic-sand-surface py-20 lg:py-24">
|
||||
<h2>Target</h2>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}`;
|
||||
writeFileSync(join(tmp, 'page.tsx'), tsx);
|
||||
|
||||
execSync(
|
||||
`node source/skills/impeccable/scripts/live-wrap.mjs --id wrapA --count 3 --classes "organic-sand-surface,py-20,lg:py-24" --tag "section" --file "${join(tmp, 'page.tsx')}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const modified = readFileSync(join(tmp, 'page.tsx'), 'utf-8');
|
||||
|
||||
// Wrapper landed somewhere.
|
||||
assert.ok(modified.includes('impeccable-variants-start wrapA'), 'wrapper was created');
|
||||
|
||||
// Decoy section survives intact — all three of its lines still present in order.
|
||||
const decoyIntact =
|
||||
/<section\s*\n\s*className="organic-sand-surface public-arc-top-section/.test(modified) &&
|
||||
/id="marketplace-intro"/.test(modified);
|
||||
assert.ok(decoyIntact, 'decoy section opening tag was not mangled');
|
||||
|
||||
// Target section sits inside the original variant wrapper.
|
||||
const originalMatch = modified.match(/data-impeccable-variant="original"[^>]*>([\s\S]*?)\s*<\/div>/);
|
||||
assert.ok(originalMatch, 'original variant wrapper exists');
|
||||
const inside = originalMatch[1];
|
||||
assert.ok(inside.includes('py-20 lg:py-24'), 'target section (with py-20 lg:py-24) is inside original wrapper');
|
||||
assert.ok(!inside.includes('public-arc-top-section'), 'decoy section is NOT inside original wrapper');
|
||||
});
|
||||
|
||||
it('emits JSX-safe style attribute ({{ }}) in .tsx files', () => {
|
||||
const tsx = `export default function App() {
|
||||
return (
|
||||
<main>
|
||||
<section className="target">
|
||||
<h1>Hi</h1>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}`;
|
||||
writeFileSync(join(tmp, 'App.tsx'), tsx);
|
||||
|
||||
execSync(
|
||||
`node source/skills/impeccable/scripts/live-wrap.mjs --id jsxStyle --count 3 --classes "target" --tag "section" --file "${join(tmp, 'App.tsx')}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const modified = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
|
||||
|
||||
// HTML-attribute style="..." is invalid JSX (parses then type-errors in strict setups).
|
||||
assert.ok(
|
||||
!/style\s*=\s*"display:\s*contents"/.test(modified),
|
||||
'no HTML-style style attribute on outer wrapper'
|
||||
);
|
||||
// JSX-safe object syntax instead.
|
||||
assert.ok(
|
||||
/style=\{\{\s*display:\s*["']contents["']\s*\}\}/.test(modified),
|
||||
'outer wrapper uses JSX style={{ display: "contents" }}'
|
||||
);
|
||||
});
|
||||
|
||||
it('finds elements via className= (React) when the exact class combo is unique there', () => {
|
||||
// Both divs contain `target-marker`, but only one shares `shared-class` with it.
|
||||
// A substring-only search would hit the decoy first; the full className match
|
||||
// disambiguates — requires the query generator to emit className="..." too.
|
||||
const tsx = `export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<div className="extra-class target-marker">Decoy</div>
|
||||
<div className="shared-class target-marker">Target</div>
|
||||
</main>
|
||||
);
|
||||
}`;
|
||||
writeFileSync(join(tmp, 'Page.tsx'), tsx);
|
||||
|
||||
execSync(
|
||||
`node source/skills/impeccable/scripts/live-wrap.mjs --id classNameA --count 3 --classes "shared-class,target-marker" --tag "div" --file "${join(tmp, 'Page.tsx')}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const modified = readFileSync(join(tmp, 'Page.tsx'), 'utf-8');
|
||||
|
||||
const originalMatch = modified.match(/data-impeccable-variant="original"[^>]*>([\s\S]*?)\s*<\/div>/);
|
||||
assert.ok(originalMatch, 'original variant wrapper exists');
|
||||
const inside = originalMatch[1];
|
||||
assert.ok(inside.includes('shared-class target-marker'), 'correct target wrapped');
|
||||
assert.ok(!inside.includes('extra-class'), 'decoy not wrapped');
|
||||
});
|
||||
|
||||
it('respects --tag to reject matches inside the wrong element type', () => {
|
||||
// Two elements, both containing the class. The <div> comes first in source
|
||||
// order; a tag-agnostic search would wrap it. With --tag section, the
|
||||
// <section> is the only valid target.
|
||||
const html = `<main>
|
||||
<div class="ambiguous-name">Decoy div</div>
|
||||
<section class="ambiguous-name">Target section</section>
|
||||
</main>`;
|
||||
writeFileSync(join(tmp, 'index.html'), html);
|
||||
|
||||
execSync(
|
||||
`node source/skills/impeccable/scripts/live-wrap.mjs --id tagFilter --count 3 --classes "ambiguous-name" --tag "section" --file "${join(tmp, 'index.html')}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const modified = readFileSync(join(tmp, 'index.html'), 'utf-8');
|
||||
const originalMatch = modified.match(/data-impeccable-variant="original"[^>]*>([\s\S]*?)\s*<\/div>/);
|
||||
assert.ok(originalMatch, 'original variant wrapper exists');
|
||||
const inside = originalMatch[1];
|
||||
assert.ok(inside.includes('<section'), 'section was wrapped');
|
||||
assert.ok(inside.includes('Target section'), 'target content is inside wrapper');
|
||||
assert.ok(!inside.includes('Decoy div'), 'div decoy was not wrapped');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findClosingLine — edge cases', () => {
|
||||
it('recognises an opener line where the tag sits at end-of-line (multi-line JSX)', () => {
|
||||
const lines = [
|
||||
'<section',
|
||||
' className="hero"',
|
||||
'>',
|
||||
' <h1>Hi</h1>',
|
||||
'</section>',
|
||||
];
|
||||
// findClosingLine should treat line 0 as a valid opener and span to line 4.
|
||||
assert.equal(findClosingLine(lines, 0), 4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user