diff --git a/.changeset/fix-markdown-heading-newline.md b/.changeset/fix-markdown-heading-newline.md new file mode 100644 index 0000000..41af1d9 --- /dev/null +++ b/.changeset/fix-markdown-heading-newline.md @@ -0,0 +1,5 @@ +--- +'@vortexlabs/kirari': patch +--- + +Fix Markdown heading tokens including the trailing line break. The heading now ends at the end of its line and the line break is a separate text token, so line-based renderers and heading styles stop at the line end. diff --git a/packages/kirari/src/languages/markdown.ts b/packages/kirari/src/languages/markdown.ts index acd313d..7393bda 100644 --- a/packages/kirari/src/languages/markdown.ts +++ b/packages/kirari/src/languages/markdown.ts @@ -20,6 +20,12 @@ const markdownTokens = (source: string, language: Language): Token[] => { const emit = (type: string, start: number, end: number) => { if (end > start) tokens.push({ type, value: source.slice(start, end), start, end }) } + // A heading ends at its line break; the break itself is text, as after any other line. + const emitHeading = (start: number, line: string) => { + const end = start + line.replace(/[\r\n]+$/, '').length + emit('heading', start, end) + emit('text', end, start + line.length) + } const emitInline = (value: string, start: number) => { for (const token of tokenize(value, base)) tokens.push({ ...token, start: token.start + start, end: token.end + start }) } @@ -38,11 +44,11 @@ const markdownTokens = (source: string, language: Language): Token[] => { fence = { char: open[1]![0]!, length: open[1]!.length, start: offset } } else if (/^ {0,3}#{1,6}(?:[ \t]|$)/.test(body) || (body.trim() && /^ {0,3}(?:=+|-+)[ \t]*(?:\r?\n|\r)?$/.test(lines[index + 1] ?? ''))) { - emit('heading', offset, offset + line.length) + emitHeading(offset, line) if (!/^ {0,3}#/.test(body)) { offset += line.length const underline = lines[++index]! - emit('heading', offset, offset + underline.length) + emitHeading(offset, underline) offset += underline.length continue } diff --git a/packages/kirari/test/common-formats.test.ts b/packages/kirari/test/common-formats.test.ts index 9140921..fdb03b5 100644 --- a/packages/kirari/test/common-formats.test.ts +++ b/packages/kirari/test/common-formats.test.ts @@ -22,11 +22,13 @@ test('CSS selectors, custom properties, dimensions, colors, and functions', () = test('Markdown headings, links, inline code, and fence length', () => { const source = '# Heading\r\nText **bold** and [site](https://example.com), ``a ` b``.\n~~~~ js\n# code\n~~~\n~~~~\nAfter\n' const result = pairs(source, markdown) - expect(result).toContainEqual(['heading', '# Heading\r\n']) + expect(result).toContainEqual(['heading', '# Heading']) expect(result).toContainEqual(['string', '[site](https://example.com)']) expect(result).toContainEqual(['string', '``a ` b``']) expect(result).toContainEqual(['string', '~~~~ js\n# code\n~~~\n~~~~\n']) - expect(pairs('Title\n=====\n', markdown)).toEqual([['heading', 'Title\n'], ['heading', '=====\n']]) + expect(pairs('Title\n=====\n', markdown)).toEqual([['heading', 'Title'], ['heading', '=====']]) + expect(tokenize('# Title\nnext', markdown).map(token => [token.type, token.value])).toEqual([['heading', '# Title'], ['text', '\n'], ['text', 'next']]) + expect(tokenize('Title\r\n---\r\n', markdown).map(token => [token.type, token.value])).toEqual([['heading', 'Title'], ['text', '\r\n'], ['heading', '---'], ['text', '\r\n']]) expect(pairs('```js\nunfinished', markdown)).toEqual([['string', '```js\nunfinished']]) }) diff --git a/packages/kirari/test/markdown-fences.test.ts b/packages/kirari/test/markdown-fences.test.ts index 0435728..7672f29 100644 --- a/packages/kirari/test/markdown-fences.test.ts +++ b/packages/kirari/test/markdown-fences.test.ts @@ -14,7 +14,7 @@ const pairs = (source: string, language = enhanced) => tokenize(source, language test('highlights multilingual fences using the first info word and keeps Markdown outside', () => { const source = '# Examples\n```js title="demo"\nconst answer = 42\n```\n~~~PY\ndef greet():\n return True\n~~~\n```data.json\n{"ready": true}\n```\nText **bold** and `code`.\n' const result = pairs(source) - for (const pair of [['heading', '# Examples\n'], ['string', '```js title="demo"\n'], ['keyword', 'const'], + for (const pair of [['heading', '# Examples'], ['text', '\n'], ['string', '```js title="demo"\n'], ['keyword', 'const'], ['keyword', 'def'], ['boolean', 'True'], ['property', '"ready"'], ['boolean', 'true'], ['string', '`code`']]) { expect(result).toContainEqual(pair) } @@ -35,7 +35,7 @@ test.each([ '```js\nconst answer = 42\n``` trailing\n```\n# After\n', ])('reuses Markdown fence indentation, character, length, and trailing-text boundaries', source => { expect(pairs(source)).toContainEqual(['keyword', 'const']) - expect(pairs(source)).toContainEqual(['heading', source.includes('\r\n') ? '# After\r\n' : '# After\n']) + expect(pairs(source).slice(-2)).toEqual([['heading', '# After'], ['text', source.includes('\r\n') ? '\r\n' : '\n']]) expect(tokenize(source, enhanced).map(token => token.value).join('')).toBe(source) }) @@ -78,7 +78,7 @@ test('renders escaped source and retains caller-provided outer Markdown rules', test('does not recursively inject languages into Markdown fences', () => { const nested = createMarkdown(createRegistry([enhanced, javascript])) const source = '````md\n# Inside\n```js\nconst x = 1\n```\n````\n' - expect(pairs(source, nested)).toContainEqual(['heading', '# Inside\n']) + expect(pairs(source, nested)).toContainEqual(['heading', '# Inside']) expect(pairs(source, nested)).toContainEqual(['string', '```js\nconst x = 1\n```\n']) expect(pairs(source, nested)).not.toContainEqual(['keyword', 'const']) }) diff --git a/sites/docs/changelog/kirari/1.1.3.md b/sites/docs/changelog/kirari/1.1.3.md new file mode 100644 index 0000000..cb14fc4 --- /dev/null +++ b/sites/docs/changelog/kirari/1.1.3.md @@ -0,0 +1,9 @@ +--- +package: kirari +version: 1.1.3 +date: 2026-10-22 +--- + +### Bug fixes + +- Markdown heading tokens no longer include the trailing line break, so heading styles and line-based renderers stop at the end of the line.