From 99cccc2f9b58c359b4678df6068402cd78d60e61 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Apr 2026 01:09:49 -0700 Subject: [PATCH] fix(live-inject): preserve indentation on remove, no orphan blank line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insert path puts the block's opener line right after the anchor's indent (e.g. six spaces plus ), 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: ` ` After remove: ` \n` (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 - JSX layout with indented (EAC shape) - Multi-file batch - Column-0 (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) --- package.json | 2 +- .../skills/impeccable/scripts/live-inject.mjs | 14 +- tests/live-inject.test.mjs | 154 ++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 tests/live-inject.test.mjs diff --git a/package.json b/package.json index 0e5cba6e8..ee253fc6e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs index 0a08e0a7f..9bbc345ac 100644 --- a/source/skills/impeccable/scripts/live-inject.mjs +++ b/source/skills/impeccable/scripts/live-inject.mjs @@ -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. + * ``), 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\S]*?\n?/, - /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + /([ \t]*)[\s\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; diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs new file mode 100644 index 000000000..49f8d2c62 --- /dev/null +++ b/tests/live-inject.test.mjs @@ -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 = ` + + Test + +
+

Hello

+
+ + +`; + const file = join(tmp, 'index.html'); + writeFileSync(file, original); + + const config = { + files: ['index.html'], + insertBefore: '', + 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 inside a typed RootLayout return. + const original = `export default async function RootLayout({ children }) { + return ( + + + {children} + + + + ); +} +`; + const file = join(tmp, 'layout.tsx'); + writeFileSync(file, original); + + const config = { + files: ['layout.tsx'], + insertBefore: '', + 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': ` + +

A

+ + +`, + 'b.html': ` + +

B

+ + +`, + }; + 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: '', + 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 )', () => { + const original = ` + +

Content

+ + +`; + const file = join(tmp, 'flat.html'); + writeFileSync(file, original); + + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + files: ['flat.html'], + insertBefore: '', + 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'); + }); +});