Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: CI
on:
push:
branches: ["master"]
pull_request:
jobs:
lint:
Expand Down
8 changes: 8 additions & 0 deletions src/components/Markdown/Markdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ describe('Markdown', () => {
const { getByText } = render(<Markdown text={text} />)
expect(getByText('This is a blockquote.')).toBeDefined()
})

it('soft line break creates a break tag', () => {
// Models often expect newlines to be presented as line breaks
const text = 'Line one\nLine two'
const { container } = render(<Markdown text={text} />)
expect(container.innerHTML).toContain('Line one<br>Line two')
expect(container.querySelector('br')).toBeDefined()
})
})

describe('Markdown horizontal rules', () => {
Expand Down
34 changes: 32 additions & 2 deletions src/components/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ interface MarkdownProps {
className?: string
}

// Enable soft line breaks (single newline becomes <br>)
// Models often expect newlines to be presented as line breaks.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const softLineBreaks = true

type Token =
| { type: 'text', content: string }
| { type: 'break' }
| { type: 'bold', children: Token[] }
| { type: 'italic', children: Token[] }
| { type: 'code', content: string }
Expand Down Expand Up @@ -152,7 +158,7 @@ function parseMarkdown(text: string): Token[] {
}
tokens.push({
type: 'paragraph',
children: parseInline(paraLines.join(' ')),
children: parseInline(paraLines.join('\n')),
})
}

Expand Down Expand Up @@ -249,13 +255,18 @@ function parseList(lines: string[], start: number, baseIndent: number): [Token[]
return [items, i]
}

/**
* Convert inline tokens back to plain text (for alt text, link text, etc.)
*/
function tokensToString(tokens: Token[]): string {
return tokens
.map(token => {
switch (token.type) {
case 'text':
case 'code':
return token.content
case 'break':
return ' '
case 'bold':
case 'italic':
case 'link':
Expand All @@ -272,6 +283,10 @@ function parseInline(text: string): Token[] {
return tokens
}

/**
* Recursively parse inline markdown elements.
* Returns a tuple of [tokens, charsConsumed].
*/
function parseInlineRecursive(text: string, stop?: string): [Token[], number] {
const tokens: Token[] = []
let i = 0
Expand Down Expand Up @@ -417,6 +432,7 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] {
let j = i
while (
j < text.length &&
(text[j] !== '\n' || !softLineBreaks) &&
text[j] !== '`' &&
!(text.startsWith('**', j) || text.startsWith('__', j)) &&
text[j] !== '*' &&
Expand All @@ -431,6 +447,13 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] {
j++
}

// soft line break
if (softLineBreaks && text[i] === '\n') {
tokens.push({ type: 'break' })
i++
continue
}

if (j === i) {
// didn't consume anything – treat the single char literally
tokens.push({ type: 'text', content: text[i] ?? '' })
Expand All @@ -444,7 +467,9 @@ function parseInlineRecursive(text: string, stop?: string): [Token[], number] {
return [tokens, i]
}

/** Split a table row, trimming outer pipes and whitespace */
/**
* Split a table row, trimming outer pipes and whitespace
*/
function splitTableRow(row: string): string[] {
const cells: string[] = []
let current = ''
Expand Down Expand Up @@ -504,12 +529,17 @@ function isClosingAsterisk(text: string, pos: number): boolean {
return !/\s/.test(prev) // prev char is not whitespace
}

/**
* Render tokens to React elements.
*/
function renderTokens(tokens: Token[], keyPrefix = ''): ReactNode[] {
return tokens.map((token, index) => {
const key = `${keyPrefix}${index}`
switch (token.type) {
case 'text':
return token.content
case 'break':
return createElement('br', { key })
case 'code':
return createElement('code', { key }, token.content)
case 'bold':
Expand Down