From 9560c1f34246d13ecab3735d1ff3cb91ea4a0234 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 11 Sep 2026 15:19:22 +0800 Subject: [PATCH 1/3] fix: preserve inline code capitalization in CLI --- src/core.ts | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core.ts b/src/core.ts index bb0458c..a36f76f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -17,6 +17,31 @@ const isTerm = (word: string, line: string) => { }); }; +const findCodeSpanEnd = (line: string, start: number) => { + let openingEnd = start + 1; + while (line[openingEnd] === '`') { + openingEnd++; + } + + const delimiterLength = openingEnd - start; + let closingStart = line.indexOf('`', openingEnd); + + while (closingStart !== -1) { + let closingEnd = closingStart + 1; + while (line[closingEnd] === '`') { + closingEnd++; + } + + if (closingEnd - closingStart === delimiterLength) { + return closingEnd; + } + closingStart = line.indexOf('`', closingEnd); + } + + // An unmatched backtick run is literal text, not an inline code span. + return openingEnd; +}; + const lineToWords = (line: string) => { const words: WordMeta[] = []; @@ -25,8 +50,19 @@ const lineToWords = (line: string) => { value: '', }; - for (const char of line.split('')) { - if (/\s/.test(char)) { + for (let index = 0; index < line.length; index++) { + let char = line[index]; + + if (char === '\\' && /[\\`]/.test(line[index + 1] ?? '')) { + char += line[++index]; + } else if (char === '`') { + // Keep code spans in a single token so their contents are never cased. + const end = findCodeSpanEnd(line, index); + char = line.slice(index, end); + index = end - 1; + } + + if (/^\s$/.test(char)) { if (lastWord.type === 'space') { lastWord.value += char; } else { From b1bf7b4508b6c90ca7574b4b3b5538368569d6e3 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 11 Sep 2026 15:21:58 +0800 Subject: [PATCH 2/3] refactor: simplify inline code tokenization --- src/core.ts | 41 +++++------------------------------------ test/index.test.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/src/core.ts b/src/core.ts index a36f76f..68b133a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -17,31 +17,6 @@ const isTerm = (word: string, line: string) => { }); }; -const findCodeSpanEnd = (line: string, start: number) => { - let openingEnd = start + 1; - while (line[openingEnd] === '`') { - openingEnd++; - } - - const delimiterLength = openingEnd - start; - let closingStart = line.indexOf('`', openingEnd); - - while (closingStart !== -1) { - let closingEnd = closingStart + 1; - while (line[closingEnd] === '`') { - closingEnd++; - } - - if (closingEnd - closingStart === delimiterLength) { - return closingEnd; - } - closingStart = line.indexOf('`', closingEnd); - } - - // An unmatched backtick run is literal text, not an inline code span. - return openingEnd; -}; - const lineToWords = (line: string) => { const words: WordMeta[] = []; @@ -50,18 +25,12 @@ const lineToWords = (line: string) => { value: '', }; - for (let index = 0; index < line.length; index++) { - let char = line[index]; - - if (char === '\\' && /[\\`]/.test(line[index + 1] ?? '')) { - char += line[++index]; - } else if (char === '`') { - // Keep code spans in a single token so their contents are never cased. - const end = findCodeSpanEnd(line, index); - char = line.slice(index, end); - index = end - 1; - } + // Consume escapes and code spans before individual characters. Code delimiters + // must be complete backtick runs of equal length; unmatched runs stay literal. + const tokens = + line.match(/\\[\\`]|(`+)(?!`)[\s\S]*?[^`]\1(?!`)|`+|[\s\S]/g) ?? []; + for (const char of tokens) { if (/^\s$/.test(char)) { if (lastWord.type === 'space') { lastWord.value += char; diff --git a/test/index.test.js b/test/index.test.js index bc5e277..c21c438 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -49,6 +49,25 @@ test('should format line as expected', () => { ); }); +test('should preserve inline code while formatting heading text', () => { + const cases = [ + ['## Use `Foo Bar Baz` Component', '## Use `Foo Bar Baz` component'], + [ + '## Use ``Foo `Bar` Baz`` Component', + '## Use ``Foo `Bar` Baz`` component', + ], + ['## Use `Foo Bar Component', '## Use `Foo bar component'], + [ + '## Use \\`Foo Bar Baz\\` Component', + '## Use \\`Foo bar Baz\\` component', + ], + ]; + + for (const [input, expected] of cases) { + assert.strictEqual(formatLine(input), expected); + } +}); + test('should preserve the existing CLI check and write usage', async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'heading-case-cli-')); const filePath = path.join(cwd, 'guide.md'); From 44bcff08a6d207edb579e9f89f1d4c874123d0d1 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 11 Sep 2026 15:31:17 +0800 Subject: [PATCH 3/3] fix: scan inline code without regex backtracking --- src/core.ts | 43 ++++++++++++++++++++++++++++++++++++++----- test/index.test.js | 1 + 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/core.ts b/src/core.ts index 68b133a..d577389 100644 --- a/src/core.ts +++ b/src/core.ts @@ -17,6 +17,34 @@ const isTerm = (word: string, line: string) => { }); }; +const indexCodeSpans = (line: string) => { + const spans = new Map(); + const nextEnds = new Map(); + + // Index the nearest closing run of each length without rescanning the suffix. + for (let end = line.length; end > 0;) { + if (line[end - 1] !== '`') { + end--; + continue; + } + let start = end - 1; + while (start > 0 && line[start - 1] === '`') { + start--; + } + + const length = end - start; + spans.set(start, nextEnds.get(length) ?? end); + // An escaped first backtick leaves the rest of the run as a possible opener. + if (length > 1) { + spans.set(start + 1, nextEnds.get(length - 1) ?? end); + } + nextEnds.set(length, end); + end = start; + } + + return spans; +}; + const lineToWords = (line: string) => { const words: WordMeta[] = []; @@ -25,12 +53,17 @@ const lineToWords = (line: string) => { value: '', }; - // Consume escapes and code spans before individual characters. Code delimiters - // must be complete backtick runs of equal length; unmatched runs stay literal. - const tokens = - line.match(/\\[\\`]|(`+)(?!`)[\s\S]*?[^`]\1(?!`)|`+|[\s\S]/g) ?? []; + const codeSpans = indexCodeSpans(line); - for (const char of tokens) { + for (let index = 0; index < line.length; index++) { + let char = line[index]; + if (char === '\\' && /[\\`]/.test(line[index + 1] ?? '')) { + char += line[++index]; + } else if (char === '`') { + const end = codeSpans.get(index)!; + char = line.slice(index, end); + index = end - 1; + } if (/^\s$/.test(char)) { if (lastWord.type === 'space') { lastWord.value += char; diff --git a/test/index.test.js b/test/index.test.js index c21c438..1967f42 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -61,6 +61,7 @@ test('should preserve inline code while formatting heading text', () => { '## Use \\`Foo Bar Baz\\` Component', '## Use \\`Foo bar Baz\\` component', ], + ['## Use \\``Foo Bar Baz` Component', '## Use \\``Foo Bar Baz` component'], ]; for (const [input, expected] of cases) {