-
Notifications
You must be signed in to change notification settings - Fork 102
fix(components): render TeX bracket delimiters #396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,315 @@ type InlineMathNode = MdastNode & { | |
| }; | ||
| }; | ||
|
|
||
| type TexMathDelimiter = { | ||
| kind: 'inline' | 'display'; | ||
| index: number; | ||
| }; | ||
|
|
||
| type MarkdownContainer = | ||
| | { kind: 'blockquote' } | ||
| | { | ||
| kind: 'indent'; | ||
| size: number; | ||
| }; | ||
|
|
||
| type MarkdownFence = { | ||
| containers: MarkdownContainer[]; | ||
| marker: '`' | '~'; | ||
| size: number; | ||
| }; | ||
|
|
||
| const lineEndAfter = (value: string, start: number): number => { | ||
| const newline = value.indexOf('\n', start); | ||
| return newline === -1 ? value.length : newline + 1; | ||
| }; | ||
|
|
||
| const lineContentEnd = (value: string, lineStart: number): number => { | ||
| const newline = value.indexOf('\n', lineStart); | ||
| const end = newline === -1 ? value.length : newline; | ||
| return end > lineStart && value[end - 1] === '\r' ? end - 1 : end; | ||
| }; | ||
|
|
||
| const indentedCodeLineEnd = (value: string, lineStart: number): number | null => { | ||
| const contentEnd = lineContentEnd(value, lineStart); | ||
| let column = 0; | ||
| let cursor = lineStart; | ||
|
|
||
| while (cursor < contentEnd) { | ||
| if (value[cursor] === ' ') { | ||
| column += 1; | ||
| } else if (value[cursor] === '\t') { | ||
| column += 4 - (column % 4); | ||
| } else { | ||
| return null; | ||
| } | ||
|
|
||
| cursor += 1; | ||
| if (column >= 4) return lineEndAfter(value, lineStart); | ||
| } | ||
|
|
||
| return null; | ||
| }; | ||
|
|
||
| const spacesEnd = (value: string, start: number, end: number, maximum: number): number => { | ||
| let cursor = start; | ||
| while (cursor < end && cursor - start < maximum && value[cursor] === ' ') cursor += 1; | ||
| return cursor; | ||
| }; | ||
|
|
||
| const listMarkerEnd = (value: string, start: number, end: number): number | null => { | ||
| let cursor = start; | ||
| const marker = value[cursor]; | ||
|
|
||
| if (marker === '-' || marker === '+' || marker === '*') { | ||
| cursor += 1; | ||
| } else { | ||
| const digitStart = cursor; | ||
| while (cursor < end && cursor - digitStart < 9 && /\d/.test(value[cursor])) cursor += 1; | ||
| if (cursor === digitStart || (value[cursor] !== '.' && value[cursor] !== ')')) return null; | ||
| cursor += 1; | ||
| } | ||
|
|
||
| if (value[cursor] !== ' ' && value[cursor] !== '\t') return null; | ||
|
|
||
| const whitespaceStart = cursor; | ||
| while (cursor < end && (value[cursor] === ' ' || value[cursor] === '\t')) cursor += 1; | ||
|
|
||
| // CommonMark treats one to four spaces as list-marker padding. With five or | ||
| // more, only the first belongs to the marker and the rest indent the content. | ||
| return cursor - whitespaceStart <= 4 ? cursor : whitespaceStart + 1; | ||
| }; | ||
|
|
||
| const markdownFenceAt = (value: string, lineStart: number): MarkdownFence | null => { | ||
| const contentEnd = lineContentEnd(value, lineStart); | ||
| let cursor = lineStart; | ||
| const containers: MarkdownContainer[] = []; | ||
|
|
||
| while (cursor < contentEnd) { | ||
| const containerStart = cursor; | ||
| cursor = spacesEnd(value, cursor, contentEnd, 3); | ||
|
|
||
| if (value[cursor] === '>') { | ||
| containers.push({ kind: 'blockquote' }); | ||
| cursor += 1; | ||
| if (value[cursor] === ' ' || value[cursor] === '\t') cursor += 1; | ||
| continue; | ||
| } | ||
|
|
||
| const markerEnd = listMarkerEnd(value, cursor, contentEnd); | ||
| if (markerEnd != null) { | ||
| containers.push({ kind: 'indent', size: markerEnd - containerStart }); | ||
| cursor = markerEnd; | ||
| continue; | ||
| } | ||
|
|
||
| break; | ||
| } | ||
|
|
||
| const marker = value[cursor]; | ||
| if (marker !== '`' && marker !== '~') return null; | ||
|
|
||
| let runEnd = cursor; | ||
| while (runEnd < contentEnd && value[runEnd] === marker) runEnd += 1; | ||
| const size = runEnd - cursor; | ||
| if (size < 3) return null; | ||
|
|
||
| // A backtick fence cannot contain another backtick in its info string. | ||
| if (marker === '`' && value.slice(runEnd, contentEnd).includes('`')) return null; | ||
|
|
||
| return { containers, marker, size }; | ||
| }; | ||
|
|
||
| const markdownContainerContentStart = ( | ||
| value: string, | ||
| lineStart: number, | ||
| contentEnd: number, | ||
| containers: readonly MarkdownContainer[] | ||
| ): number | null => { | ||
| let cursor = lineStart; | ||
|
|
||
| for (const container of containers) { | ||
| if (container.kind === 'blockquote') { | ||
| cursor = spacesEnd(value, cursor, contentEnd, 3); | ||
| if (value[cursor] !== '>') return null; | ||
| cursor += 1; | ||
| if (value[cursor] === ' ' || value[cursor] === '\t') cursor += 1; | ||
| continue; | ||
| } | ||
|
|
||
| for (let index = 0; index < container.size; index += 1) { | ||
| if (value[cursor] !== ' ') return null; | ||
| cursor += 1; | ||
| } | ||
| } | ||
|
|
||
| return cursor; | ||
| }; | ||
|
|
||
| const isClosingMarkdownFence = ( | ||
| value: string, | ||
| lineStart: number, | ||
| fence: MarkdownFence | ||
| ): boolean => { | ||
| const contentEnd = lineContentEnd(value, lineStart); | ||
| const contentStart = markdownContainerContentStart( | ||
| value, | ||
| lineStart, | ||
| contentEnd, | ||
| fence.containers | ||
| ); | ||
| if (contentStart == null) return false; | ||
| let cursor = spacesEnd(value, contentStart, contentEnd, 3); | ||
|
|
||
| const runStart = cursor; | ||
| while (cursor < contentEnd && value[cursor] === fence.marker) cursor += 1; | ||
| if (cursor - runStart < fence.size) return false; | ||
|
|
||
| while (cursor < contentEnd && (value[cursor] === ' ' || value[cursor] === '\t')) { | ||
| cursor += 1; | ||
| } | ||
| return cursor === contentEnd; | ||
| }; | ||
|
|
||
| const fencedCodeEnd = (value: string, lineStart: number, fence: MarkdownFence): number => { | ||
| let cursor = lineEndAfter(value, lineStart); | ||
|
|
||
| while (cursor < value.length) { | ||
| const nextLine = lineEndAfter(value, cursor); | ||
| if (isClosingMarkdownFence(value, cursor, fence)) return nextLine; | ||
| cursor = nextLine; | ||
| } | ||
|
|
||
| return value.length; | ||
| }; | ||
|
|
||
| const backtickRunLength = (value: string, start: number): number => { | ||
| let cursor = start; | ||
| while (cursor < value.length && value[cursor] === '`') cursor += 1; | ||
| return cursor - start; | ||
| }; | ||
|
|
||
| const inlineCodeEnd = (value: string, start: number, size: number): number | null => { | ||
| let cursor = start + size; | ||
|
|
||
| while (cursor < value.length) { | ||
| const next = value.indexOf('`', cursor); | ||
| if (next === -1) return null; | ||
|
|
||
| const nextSize = backtickRunLength(value, next); | ||
| if (nextSize === size) return next + nextSize; | ||
| cursor = next + nextSize; | ||
| } | ||
|
|
||
| return null; | ||
| }; | ||
|
|
||
| const slashRunLength = (value: string, start: number): number => { | ||
| let cursor = start; | ||
| while (cursor < value.length && value[cursor] === '\\') cursor += 1; | ||
| return cursor - start; | ||
| }; | ||
|
|
||
| /** | ||
| * Normalizes TeX's `\\(...\\)` and `\\[...\\]` delimiters to the double-dollar | ||
| * form understood by remark-math. This must run before Streamdown splits the | ||
| * Markdown into blocks: otherwise a display formula containing a line such as | ||
| * `=` can already have been classified as a Markdown heading. | ||
| * | ||
| * Only complete, matching pairs outside code spans/blocks are rewritten. Each | ||
| * delimiter remains two characters wide, so source offsets used by later | ||
| * Markdown transforms stay valid. | ||
| */ | ||
| export const normalizeTexMathDelimiters = (value: string): string => { | ||
| const replacements: number[] = []; | ||
| let opening: TexMathDelimiter | null = null; | ||
| let cursor = 0; | ||
| let lineStart = 0; | ||
|
|
||
| while (cursor < value.length) { | ||
| if (cursor === lineStart) { | ||
| const codeLineEnd = opening == null ? indentedCodeLineEnd(value, lineStart) : null; | ||
| if (codeLineEnd != null) { | ||
| cursor = codeLineEnd; | ||
| lineStart = cursor; | ||
| continue; | ||
| } | ||
|
|
||
| const fence = markdownFenceAt(value, lineStart); | ||
| if (fence) { | ||
| opening = null; | ||
| cursor = fencedCodeEnd(value, lineStart, fence); | ||
|
Comment on lines
+244
to
+255
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Handle fenced blocks inside Markdown containers before rewriting delimiters. For example, in Useful? React with 👍 / 👎.
Comment on lines
+244
to
+255
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Markdown uses a standard four-space-indented code block, such as Useful? React with 👍 / 👎. |
||
| lineStart = cursor; | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| const current = value[cursor]; | ||
| if (current === '\n') { | ||
| if (opening?.kind === 'inline') opening = null; | ||
| cursor += 1; | ||
| lineStart = cursor; | ||
| continue; | ||
| } | ||
|
|
||
| if (current === '`') { | ||
| const size = backtickRunLength(value, cursor); | ||
| const end = inlineCodeEnd(value, cursor, size); | ||
| if (end != null) { | ||
| opening = null; | ||
| cursor = end; | ||
| lineStart = value.lastIndexOf('\n', cursor - 1) + 1; | ||
| continue; | ||
| } | ||
| cursor += size; | ||
| continue; | ||
| } | ||
|
|
||
| if (current !== '\\') { | ||
| cursor += 1; | ||
| continue; | ||
| } | ||
|
|
||
| const slashSize = slashRunLength(value, cursor); | ||
| const delimiterIndex = cursor + slashSize - 1; | ||
| const delimiterMarker = value[delimiterIndex + 1]; | ||
|
|
||
| // Pairs of slashes escape each other. With an odd run, only its final | ||
| // slash participates in the TeX delimiter and any preceding pairs remain. | ||
| if ( | ||
| slashSize % 2 === 0 || | ||
| (delimiterMarker !== '(' && | ||
| delimiterMarker !== ')' && | ||
| delimiterMarker !== '[' && | ||
| delimiterMarker !== ']') | ||
| ) { | ||
| cursor += slashSize; | ||
| continue; | ||
| } | ||
|
|
||
| const kind = delimiterMarker === '(' || delimiterMarker === ')' ? 'inline' : 'display'; | ||
| const isOpening = delimiterMarker === '(' || delimiterMarker === '['; | ||
|
|
||
| if (isOpening) { | ||
| opening = { kind, index: delimiterIndex }; | ||
| } else if (opening?.kind === kind) { | ||
| replacements.push(opening.index, delimiterIndex); | ||
| opening = null; | ||
|
Comment on lines
+307
to
+311
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a link or image destination contains Markdown-escaped balanced parentheses, for example AGENTS.md reference: packages/components/src/lib/AGENTS.md:L31-L40 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| cursor = delimiterIndex + 2; | ||
| } | ||
|
|
||
| if (replacements.length === 0) return value; | ||
|
|
||
| const normalized = value.split(''); | ||
| replacements.forEach((index) => { | ||
| normalized[index] = '$'; | ||
| normalized[index + 1] = '$'; | ||
| }); | ||
| return normalized.join(''); | ||
| }; | ||
|
|
||
| const SKIP_CHILDREN_NODE_TYPES = new Set([ | ||
| 'code', | ||
| 'definition', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a normal list continuation uses four root-relative spaces, such as
- explanation\n \(x\), CommonMark removes the list item's two-space content indent and treats the remainder as paragraph indentation, not an indented code block. This absolute-column check nevertheless skips the entire line, so TeX delimiters in commonly formatted list content remain literal; account for the active container indentation before applying the four-column code rule.Useful? React with 👍 / 👎.