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 `
Content` 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
\r\n\r\n
\r\n