diff --git a/src/core.ts b/src/core.ts index bb0458c..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,8 +53,18 @@ const lineToWords = (line: string) => { value: '', }; - for (const char of line.split('')) { - if (/\s/.test(char)) { + const codeSpans = indexCodeSpans(line); + + 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; } else { diff --git a/test/index.test.js b/test/index.test.js index bc5e277..1967f42 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -49,6 +49,26 @@ 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', + ], + ['## 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');