mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
fix(live-inject): preserve indentation on remove, no orphan blank line
The insert path puts the block's opener line right after the anchor's indent (e.g. six spaces plus </body>), which transfers the indent onto the opener line and leaves the anchor unindented in the injected file. The remove path consumed the block's trailing newline but left the pre-block indent behind, producing two bugs in one: Before insert: ` </body>` After remove: ` \n</body>` (orphan indent + unindented anchor) Fix: capture `([ \t]*)` immediately before each marker and replace the whole block (including its trailing newline and any trailing spaces on the ender line) with just the captured indent. The indent now hands itself back to the anchor line that follows — the file round-trips byte-for-byte. New tests/live-inject.test.mjs with four round-trip cases: - HTML file with indented </body> - JSX layout with indented </body> (EAC shape) - Multi-file batch - Column-0 </body> (no indent — already worked; regression baseline) All four pass after the fix. Full suite clean via `bun run test`. 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
e441e88cc1
commit
99cccc2f9b
+1
-1
@@ -49,7 +49,7 @@
|
||||
"dev": "bun run server/index.js",
|
||||
"preview": "bun run build && wrangler pages dev",
|
||||
"deploy": "bun run build && wrangler pages deploy build/",
|
||||
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-server.test.mjs && node --test tests/framework-fixtures.test.mjs",
|
||||
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/framework-fixtures.test.mjs",
|
||||
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
|
||||
"postpack": "cp README.repo.md README.md && rm README.repo.md",
|
||||
"screenshot": "bun run scripts/screenshot-antipatterns.js",
|
||||
|
||||
@@ -167,15 +167,21 @@ function insertTag(content, config, port) {
|
||||
/**
|
||||
* Remove the live script block. Matches either HTML or JSX comment markers
|
||||
* regardless of config (so stale tags from a wrong config can still be cleaned).
|
||||
*
|
||||
* Indent-preserving: captures any whitespace immediately preceding the opener
|
||||
* marker and re-emits it in place of the removed block. `insertTag` inserted
|
||||
* the block *after* the original line's indent and *before* the anchor (e.g.
|
||||
* `</body>`), which moved the indent onto the opener line and left the anchor
|
||||
* unindented. Replacing the whole block (plus its trailing newline) with just
|
||||
* the captured indent hands the indent back to the anchor that follows.
|
||||
*/
|
||||
function removeTag(content, _syntax) {
|
||||
// Two patterns: HTML comment markers or JSX comment markers, with any content between.
|
||||
const patterns = [
|
||||
/\n?<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->\n?/,
|
||||
/\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/,
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->[ \t]*\n/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
const next = content.replace(pat, '\n');
|
||||
const next = content.replace(pat, '$1');
|
||||
if (next !== content) return next;
|
||||
}
|
||||
return content;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Tests for live-inject.mjs — script-tag insert/remove round-trip.
|
||||
* Run with: node --test tests/live-inject.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const INJECT = resolve(__dirname, '..', 'source/skills/impeccable/scripts/live-inject.mjs');
|
||||
|
||||
function runInject(cwd, configPath, args) {
|
||||
try {
|
||||
const out = execFileSync('node', [INJECT, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, IMPECCABLE_LIVE_CONFIG: configPath },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return JSON.parse(out.trim());
|
||||
} catch (err) {
|
||||
const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || '';
|
||||
return JSON.parse(body || '{}');
|
||||
}
|
||||
}
|
||||
|
||||
describe('live-inject — insert/remove round-trip preserves file bytes', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-inject-test-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('round-trips an HTML file without mangling indentation', () => {
|
||||
const original = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hello</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const file = join(tmp, 'index.html');
|
||||
writeFileSync(file, original);
|
||||
|
||||
const config = {
|
||||
files: ['index.html'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
};
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify(config));
|
||||
|
||||
runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
runInject(tmp, cfgPath, ['--remove']);
|
||||
|
||||
const after = readFileSync(file, 'utf-8');
|
||||
assert.equal(after, original, 'file should match original byte-for-byte after insert/remove');
|
||||
});
|
||||
|
||||
it('round-trips a JSX layout without mangling indentation', () => {
|
||||
// Matches the EAC shape: indented </body> inside a typed RootLayout return.
|
||||
const original = `export default async function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{children}
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
`;
|
||||
const file = join(tmp, 'layout.tsx');
|
||||
writeFileSync(file, original);
|
||||
|
||||
const config = {
|
||||
files: ['layout.tsx'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'jsx',
|
||||
};
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify(config));
|
||||
|
||||
runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
runInject(tmp, cfgPath, ['--remove']);
|
||||
|
||||
const after = readFileSync(file, 'utf-8');
|
||||
assert.equal(after, original, 'JSX file should match original byte-for-byte after insert/remove');
|
||||
});
|
||||
|
||||
it('round-trips multiple files at once', () => {
|
||||
const originals = {
|
||||
'a.html': `<html>
|
||||
<body>
|
||||
<p>A</p>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'b.html': `<html>
|
||||
<body>
|
||||
<p>B</p>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
};
|
||||
for (const [name, body] of Object.entries(originals)) {
|
||||
writeFileSync(join(tmp, name), body);
|
||||
}
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['a.html', 'b.html'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
runInject(tmp, cfgPath, ['--remove']);
|
||||
|
||||
for (const [name, body] of Object.entries(originals)) {
|
||||
const after = readFileSync(join(tmp, name), 'utf-8');
|
||||
assert.equal(after, body, `${name} should match original byte-for-byte after insert/remove`);
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips when the insert anchor has no leading indent (column-0 </body>)', () => {
|
||||
const original = `<html>
|
||||
<body>
|
||||
<p>Content</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const file = join(tmp, 'flat.html');
|
||||
writeFileSync(file, original);
|
||||
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['flat.html'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
runInject(tmp, cfgPath, ['--remove']);
|
||||
|
||||
const after = readFileSync(file, 'utf-8');
|
||||
assert.equal(after, original, 'column-0 anchor should round-trip cleanly too');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user