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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
:::list{style=lower-alpha-parens}
- the relation $$x + y = z$$
- next item
:::
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Bullet list — 2-column marker depth:

- the relation $$x + y = z$$
- next item

Numbered list — 3-column marker depth:

1. the relation $$\frac{p}{q} = 1$$
2. next item
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
:::list{style=upper-alpha-parens}
- For all reals $x, y$, the relation

$$f(x+y) = f(x) + f(y).$$
- The function $f$ is continuous everywhere.
:::
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- Ratio bound:

$$\frac{a}{b} \le 1.$$
- Equality holds when $a = b$.
24 changes: 24 additions & 0 deletions web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,30 @@ exports[`validate-md fixtures > valid-display-math.md 1`] = `
}
`;

exports[`validate-md fixtures > valid-display-math-mid-line-in-directive-list.md 1`] = `
{
"verdict": "ok",
}
`;

exports[`validate-md fixtures > valid-display-math-mid-line-in-native-list.md 1`] = `
{
"verdict": "ok",
}
`;

exports[`validate-md fixtures > valid-display-math-own-line-in-directive-list.md 1`] = `
{
"verdict": "ok",
}
`;

exports[`validate-md fixtures > valid-display-math-own-line-in-native-list.md 1`] = `
{
"verdict": "ok",
}
`;

exports[`validate-md fixtures > valid-image.md 1`] = `
{
"verdict": "ok",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,52 @@ describe('preprocessDisplayMath', () => {
expect(result).toBe('\n$$\n\n$$\n')
})
})

describe('indentation preservation — own-line $$', () => {
it('preserves 2-space indent (matches a `- ` marker depth)', () => {
const result = preprocessDisplayMath(' $$x = 1$$')
expect(result).toBe(' \n $$\n x = 1\n $$\n')
})

it('preserves 3-space indent (deeper than `- ` continuation)', () => {
const result = preprocessDisplayMath(' $$x = 1$$')
expect(result).toBe(' \n $$\n x = 1\n $$\n')
})

it('preserves 4-space indent (matches a nested list-item depth)', () => {
const result = preprocessDisplayMath(' $$x = 1$$')
expect(result).toBe(' \n $$\n x = 1\n $$\n')
})

it('preserves indent across multi-line math content (and avoids double-indenting continuation lines)', () => {
const result = preprocessDisplayMath(' $$a + b\n = c$$')
expect(result).toBe(' \n $$\n a + b\n = c\n $$\n')
})
})

describe('indentation preservation — mid-line $$ inside a list item', () => {
it('indents the math block to the `- ` marker depth (2 columns)', () => {
// Math people sometimes write display math glued to the item's prose; the math block must stay in the item
const result = preprocessDisplayMath('- the relation $$xy$$')
expect(result).toBe('- the relation \n $$\n xy\n $$\n')
})

it('indents the math block to the `1. ` marker depth (3 columns)', () => {
const result = preprocessDisplayMath('1. the relation $$xy$$')
expect(result).toBe('1. the relation \n $$\n xy\n $$\n')
})

it('indents the math block to a nested ` - ` marker depth (4 columns)', () => {
const result = preprocessDisplayMath(' - the relation $$xy$$')
expect(result).toBe(' - the relation \n $$\n xy\n $$\n')
})

it('does not propagate indent when $$ is mid-line outside any list', () => {
// The two leading spaces are part of running prose, not of the $$ line itself
const result = preprocessDisplayMath(' text $$x$$')
expect(result).toBe(' text \n$$\nx\n$$\n')
})
})
})

describe('collapseExcessiveBreaks', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
/** Matches the leading list-item marker on a line (bullet `-`/`*`/`+` or numbered `1.`/`1)`) plus its trailing whitespace. */
const LIST_MARKER_PREFIX_REGEX = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/

/**
* Normalizes display math to block format.
*
* remark-math requires $$ to be on their own lines for display math,
* but in LaTeX/TeX, $$...$$ is always display math regardless of newlines.
* This function ensures all $$...$$ are properly block-formatted.
* This function ensures all $$...$$ are properly block-formatted while
* preserving any leading indentation on the original line — critical for
* display math nested inside a list item, where CommonMark needs every
* continuation line indented to match the marker depth.
*
* Three indent regimes are handled:
* - Own-line `$$` with pure-whitespace prefix → preserve that whitespace as indent.
* - Mid-line `$$` on a line that opens with a list marker (`- `, `1. `, ...) →
* indent the emitted block to the post-marker column so the math stays in the item.
* - Mid-line `$$` anywhere else → no indent (legacy behavior).
*
* @param content - The markdown content to preprocess
*
Expand All @@ -12,7 +24,45 @@
export function preprocessDisplayMath(content: string): string {
return content.replace(
/\$\$([\s\S]*?)\$\$/g,
(_match, mathContent: string) => `\n$$\n${mathContent.trim()}\n$$\n`
(_match, mathContent: string, offset: number, source: string) => {
// Look up what precedes the opening `$$` on its current line (in the original, pre-replacement source)
const lineStart = offset === 0 ? 0 : source.lastIndexOf('\n', offset - 1) + 1
const beforeMatch = source.slice(lineStart, offset)

// Three regimes — see the JSDoc above for the rationale of each
let indent: string
if (/^[ \t]*$/.test(beforeMatch)) {
// `$$` opens its own (possibly indented) line — preserve the whole leading whitespace
indent = beforeMatch
} else {
// Mid-line `$$` — try to detect a list marker on the line so the math doesn't break the item
const listMarkerMatch = LIST_MARKER_PREFIX_REGEX.exec(beforeMatch)
indent = listMarkerMatch ? ' '.repeat(listMarkerMatch[0].length) : ''
}

// Indent every line of the math body so CommonMark stays inside the enclosing list item.
// The opening `$$` consumed any leading indent on its own line; multi-line continuation
// content typically reproduces the list-item indent on every line, so strip it on
// continuation lines before re-indenting uniformly to avoid double indentation.
const indentedBody = mathContent
.trim()
.split('\n')
.map((line, lineIndex) => {
// First line never carried list-item indent in the source — it followed the `$$` directly
if (lineIndex === 0) return `${indent}${line}`

// Continuation line — strip the list-item indent if it matches what the source supplied
const stripped =
indent.length > 0 && line.startsWith(indent) ? line.slice(indent.length) : line

// Re-indent
return `${indent}${stripped}`
})
.join('\n')

// Re-indent the entire block
return `\n${indent}$$\n${indentedBody}\n${indent}$$\n`
}
)
}

Expand Down
Loading