From c587a2e0116b6efa3889e9ebf599e157d78e01d1 Mon Sep 17 00:00:00 2001 From: PatrikBak Date: Sun, 10 May 2026 19:08:58 +0200 Subject: [PATCH] Make preprocessDisplayMath indentation-aware Display math nested inside list items now stays attached to the item instead of breaking out: own-line `$$` preserves its leading indent on every emitted line, and mid-line `$$` on a list-marker line picks up the marker depth as indent. Co-Authored-By: Claude Opus 4.7 --- ...display-math-mid-line-in-directive-list.md | 4 ++ ...id-display-math-mid-line-in-native-list.md | 9 ++++ ...display-math-own-line-in-directive-list.md | 6 +++ ...id-display-math-own-line-in-native-list.md | 4 ++ .../__snapshots__/validate-md.test.ts.snap | 24 +++++++++ .../utils/__tests__/preprocessors.test.ts | 46 ++++++++++++++++ .../rich-math-editor/utils/preprocessors.ts | 54 ++++++++++++++++++- 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-directive-list.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-native-list.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-directive-list.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-native-list.md diff --git a/web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-directive-list.md b/web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-directive-list.md new file mode 100644 index 00000000..8a74e8fb --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-directive-list.md @@ -0,0 +1,4 @@ +:::list{style=lower-alpha-parens} +- the relation $$x + y = z$$ +- next item +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-native-list.md b/web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-native-list.md new file mode 100644 index 00000000..82e1eba7 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-display-math-mid-line-in-native-list.md @@ -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 diff --git a/web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-directive-list.md b/web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-directive-list.md new file mode 100644 index 00000000..2cf99fb7 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-directive-list.md @@ -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. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-native-list.md b/web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-native-list.md new file mode 100644 index 00000000..6b24aabd --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-display-math-own-line-in-native-list.md @@ -0,0 +1,4 @@ +- Ratio bound: + + $$\frac{a}{b} \le 1.$$ +- Equality holds when $a = b$. diff --git a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap index e86a34ae..c6c7d770 100644 --- a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap +++ b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap @@ -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", diff --git a/web/src/components/shared/components/rich-math-editor/utils/__tests__/preprocessors.test.ts b/web/src/components/shared/components/rich-math-editor/utils/__tests__/preprocessors.test.ts index 5a63c883..33e349cc 100644 --- a/web/src/components/shared/components/rich-math-editor/utils/__tests__/preprocessors.test.ts +++ b/web/src/components/shared/components/rich-math-editor/utils/__tests__/preprocessors.test.ts @@ -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', () => { diff --git a/web/src/components/shared/components/rich-math-editor/utils/preprocessors.ts b/web/src/components/shared/components/rich-math-editor/utils/preprocessors.ts index 460e1559..de226110 100644 --- a/web/src/components/shared/components/rich-math-editor/utils/preprocessors.ts +++ b/web/src/components/shared/components/rich-math-editor/utils/preprocessors.ts @@ -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 * @@ -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` + } ) }