From f636bd065a11234bfc7f1b0e0cd9d1ba7a0eb209 Mon Sep 17 00:00:00 2001 From: Syed Osama Ali Shah <86572800+Osamaali313@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:45:12 +0300 Subject: [PATCH] fix(live-inject): preserve the character after an insertAfter anchor (#227) (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(live-inject): preserve the character after an insertAfter anchor insertTag()'s insertAfter branch sliced the post-anchor remainder by prefix.length. When the anchor was not already followed by a newline, prefix is one character longer than the anchor (the appended '\n'), so content.slice(prefix.length) dropped the first real character after the anchor — e.g. `X...` lost the `X` during live-mode injection (#227). Slice the remainder from the original anchor offset instead. The insertBefore branch and the already-followed-by-newline case are unchanged. Add a regression test for both the no-trailing-newline and newline cases, and regenerate the tracked per-agent bundles so the fix ships everywhere. Fixes #227. Root-cause analysis from the issue reporter. * Fix live inject CRLF insertAfter handling --------- Co-authored-by: Paul Bakaus --- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .pi/skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- .../skills/impeccable/scripts/live-inject.mjs | 38 ++++++++++++--- skill/scripts/live-inject.mjs | 38 ++++++++++++--- tests/live-inject.test.mjs | 48 +++++++++++++++++++ 15 files changed, 482 insertions(+), 98 deletions(-) diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index c006ad09e..4e5c5dc0a 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -368,8 +368,26 @@ function buildTagBlock(syntax, port, filePath) { ); } +function detectLineEnding(content) { + if (content.includes('\r\n')) return '\r\n'; + if (content.includes('\r')) return '\r'; + return '\n'; +} + +function normalizeLineEndings(content, lineEnding) { + return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding); +} + +function readLineEndingAt(content, index) { + if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n'; + if (content[index] === '\n') return '\n'; + if (content[index] === '\r') return '\r'; + return ''; +} + function insertTag(content, config, port, filePath) { - const block = buildTagBlock(config.commentSyntax, port, filePath); + const lineEnding = detectLineEnding(content); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. @@ -383,9 +401,15 @@ function insertTag(content, config, port, filePath) { const idx = content.indexOf(config.insertAfter); if (idx === -1) return content; const after = idx + config.insertAfter.length; - // Preserve a single trailing newline if the anchor didn't end with one - const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; - return prefix + block + content.slice(prefix.length); + // Preserve an existing trailing newline if the anchor already has one. + // Slice the remainder from the original anchor offset, not prefix.length: + // in the no-newline case prefix is one char longer than the anchor (the + // appended '\n'), so slicing by prefix.length would drop the first real + // character after the anchor (#227). + const existingNewline = readLineEndingAt(content, after); + const prefix = content.slice(0, after) + (existingNewline || lineEnding); + const rest = content.slice(after + existingNewline.length); + return prefix + block + rest; } /** @@ -401,8 +425,8 @@ function insertTag(content, config, port, filePath) { */ function removeTag(content, _syntax) { const patterns = [ - /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/, - /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/, + /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/, ]; for (const pat of patterns) { let changed = false; @@ -410,7 +434,7 @@ function removeTag(content, _syntax) { do { content = next; next = content.replace(pat, (_match, leadingIndent, trailing = '') => { - if (trailing.includes('\n')) return leadingIndent; + if (/[\r\n]/.test(trailing)) return leadingIndent; return leadingIndent || trailing || ''; }); if (next !== content) changed = true; diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs index 8ac14fc52..bf0760fb5 100644 --- a/tests/live-inject.test.mjs +++ b/tests/live-inject.test.mjs @@ -341,4 +341,52 @@ const title = 'Test'; const after = readFileSync(file, 'utf-8'); assert.equal(after, original, 'column-0 anchor should round-trip cleanly too'); }); + + it('preserves the character after an insertAfter anchor with no trailing newline (#227)', () => { + const original = 'X'; + const file = join(tmp, 'compact.html'); + writeFileSync(file, original); + + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + files: ['compact.html'], + insertAfter: '', + commentSyntax: 'html', + })); + + runInject(tmp, cfgPath, ['--port', '8400']); + const afterInject = readFileSync(file, 'utf-8'); + + assert.ok( + afterInject.includes('\nX'), + `the character immediately after must survive injection, got:\n${afterInject}` + ); + }); + + it('round-trips insertAfter files with CRLF newlines', () => { + const original = '\r\n\r\n X\r\n\r\nContent\r\n\r\n'; + const file = join(tmp, 'crlf.html'); + writeFileSync(file, original); + + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + files: ['crlf.html'], + insertAfter: '', + commentSyntax: 'html', + })); + + runInject(tmp, cfgPath, ['--port', '8400']); + const afterInject = readFileSync(file, 'utf-8'); + + assert.ok( + afterInject.includes( + '\r\n\r\n\r\n\r\n X' + ), + `CRLF insertAfter should keep CRLF boundaries around the injected block, got:\n${JSON.stringify(afterInject)}` + ); + + runInject(tmp, cfgPath, ['--remove']); + const afterRemove = readFileSync(file, 'utf-8'); + assert.equal(afterRemove, original, 'CRLF file should round-trip cleanly after remove'); + }); });