diff --git a/web/.prettierignore b/web/.prettierignore index ab39086c..d9e55ad4 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -3,4 +3,8 @@ node_modules out dist coverage -public \ No newline at end of file +public + +# Validator fixtures contain intentionally malformed markdown; Prettier would +# auto-correct unclosed bold, angle-bracket dangerous URLs, etc. +scripts/__fixtures__/validate-md diff --git a/web/package-lock.json b/web/package-lock.json index d97e6f88..a73a56d4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -45,14 +45,18 @@ "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", "remark-breaks": "^4.0.0", "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "resend": "^6.9.1", "sonner": "^2.0.7", "svix": "^1.84.1", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "use-long-press": "^3.3.0", "zod": "^4.3.6", @@ -11263,6 +11267,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -15696,6 +15723,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-breaks": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", diff --git a/web/package.json b/web/package.json index dc724f87..2bb89d9c 100644 --- a/web/package.json +++ b/web/package.json @@ -61,14 +61,18 @@ "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", "remark-breaks": "^4.0.0", "remark-directive": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "resend": "^6.9.1", "sonner": "^2.0.7", "svix": "^1.84.1", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "use-long-press": "^3.3.0", "zod": "^4.3.6", diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-bad-environment.md b/web/scripts/__fixtures__/validate-md/invalid-katex-bad-environment.md new file mode 100644 index 00000000..df9eada8 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-bad-environment.md @@ -0,0 +1,3 @@ +Unknown environment name — KaTeX does not know `notathing`. + +$$\begin{notathing} x = 1 \end{notathing}$$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-empty-subscript.md b/web/scripts/__fixtures__/validate-md/invalid-katex-empty-subscript.md new file mode 100644 index 00000000..6f810529 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-empty-subscript.md @@ -0,0 +1,3 @@ +Subscript with no argument — `_` followed immediately by the closing delimiter. + +$x_$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-in-list.md b/web/scripts/__fixtures__/validate-md/invalid-katex-in-list.md new file mode 100644 index 00000000..6a5c59fa --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-in-list.md @@ -0,0 +1,6 @@ +A KaTeX error inside a bullet-list item must surface (not be discarded with the surrounding markdown). + +- First valid item. +- Second valid item with formula $a + b$. +- Third item has a broken formula: $\frac{a}$. +- Fourth item. diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-in-spoiler.md b/web/scripts/__fixtures__/validate-md/invalid-katex-in-spoiler.md new file mode 100644 index 00000000..1bb1bc32 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-in-spoiler.md @@ -0,0 +1,5 @@ +A KaTeX error nested inside a spoiler must propagate, not get swallowed by the directive transformer. + +:::spoiler[Hint] +Try the substitution $\foo{u}$. +::: diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-in-table.md b/web/scripts/__fixtures__/validate-md/invalid-katex-in-table.md new file mode 100644 index 00000000..1778b12d --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-in-table.md @@ -0,0 +1,6 @@ +A KaTeX error inside a GFM table cell must surface — confirms cell rendering does not silently drop math errors. + +| Operation | Formula | +| --------- | ------------- | +| valid | $a + b$ | +| broken | $\unknowncmd$ | diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-mismatched-env.md b/web/scripts/__fixtures__/validate-md/invalid-katex-mismatched-env.md new file mode 100644 index 00000000..95790e8a --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-mismatched-env.md @@ -0,0 +1,3 @@ +Mismatched begin/end — `aligned` opened, `cases` closed. + +$$\begin{aligned} x &= 1 \end{cases}$$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-missing-arg.md b/web/scripts/__fixtures__/validate-md/invalid-katex-missing-arg.md new file mode 100644 index 00000000..f4b91c51 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-missing-arg.md @@ -0,0 +1,3 @@ +`\frac` is a two-argument macro; supplying only one is a parse error. + +$\frac{a}$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-nested.md b/web/scripts/__fixtures__/validate-md/invalid-katex-nested.md new file mode 100644 index 00000000..b2bac0a8 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-nested.md @@ -0,0 +1,3 @@ +Outer command (`\sqrt`) is valid, inner command (`\foo`) is not — confirms errors surface from nested expressions. + +$\sqrt{\foo{x}}$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-stray-amp.md b/web/scripts/__fixtures__/validate-md/invalid-katex-stray-amp.md new file mode 100644 index 00000000..e961b35c --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-stray-amp.md @@ -0,0 +1,3 @@ +Stray `&` outside an alignment environment — only legal inside `aligned`, `array`, etc. + +$x & y$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-unbalanced-braces.md b/web/scripts/__fixtures__/validate-md/invalid-katex-unbalanced-braces.md new file mode 100644 index 00000000..b6722a8e --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-unbalanced-braces.md @@ -0,0 +1,3 @@ +Unbalanced braces — opening `{` after `a` is never closed. + +$\frac{a{b}$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-unclosed-brace.md b/web/scripts/__fixtures__/validate-md/invalid-katex-unclosed-brace.md new file mode 100644 index 00000000..00b41a7c --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-unclosed-brace.md @@ -0,0 +1,3 @@ +Unclosed brace inside inline math — KaTeX parser hits end-of-input expecting `}`. + +$x{$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-unclosed-env.md b/web/scripts/__fixtures__/validate-md/invalid-katex-unclosed-env.md new file mode 100644 index 00000000..6ac5ccb4 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-unclosed-env.md @@ -0,0 +1,3 @@ +Unclosed environment — `aligned` is opened but never closed. + +$$\begin{aligned} x &= 1$$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-unknown-cmd.md b/web/scripts/__fixtures__/validate-md/invalid-katex-unknown-cmd.md new file mode 100644 index 00000000..39c6ec45 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-unknown-cmd.md @@ -0,0 +1,3 @@ +Unknown control sequence — KaTeX should reject `\foo`. + +$\foo{bar}$ diff --git a/web/scripts/__fixtures__/validate-md/invalid-katex-unmatched-left.md b/web/scripts/__fixtures__/validate-md/invalid-katex-unmatched-left.md new file mode 100644 index 00000000..d4a1d4f4 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-katex-unmatched-left.md @@ -0,0 +1,3 @@ +`\left(` opens a dynamic delimiter group with no matching `\right`. + +$\left( x + y$ diff --git a/web/scripts/__fixtures__/validate-md/valid-blockquote.md b/web/scripts/__fixtures__/validate-md/valid-blockquote.md new file mode 100644 index 00000000..c90557aa --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-blockquote.md @@ -0,0 +1,5 @@ +A blockquote with embedded inline math, multi-line continuation: + +> The Cauchy-Schwarz inequality states that for any real numbers $a_i$ and $b_i$, +> $\left(\sum a_i b_i\right)^2 \le \left(\sum a_i^2\right) \left(\sum b_i^2\right)$, +> with equality if and only if the sequences are proportional. diff --git a/web/scripts/__fixtures__/validate-md/valid-code.md b/web/scripts/__fixtures__/validate-md/valid-code.md new file mode 100644 index 00000000..0ba2a7b5 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-code.md @@ -0,0 +1,8 @@ +Inline `code` span and a fenced code block — supported by the schema even if rare in problem statements. + +```python +def gcd(a, b): + while b: + a, b = b, a % b + return a +``` diff --git a/web/scripts/__fixtures__/validate-md/valid-display-math.md b/web/scripts/__fixtures__/validate-md/valid-display-math.md new file mode 100644 index 00000000..2de402ca --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-display-math.md @@ -0,0 +1,7 @@ +Single-line display math: + +$$\int_0^1 x \, dx = \frac{1}{2}$$ + +The canonical archive example combining several means: + +$$a=\frac{x+y}{2},\ g=\sqrt{xy},\ h=\frac{2xy}{x+y},\ k=\sqrt{\frac{x^2+y^2}{2}}.$$ diff --git a/web/scripts/__fixtures__/validate-md/valid-image.md b/web/scripts/__fixtures__/validate-md/valid-image.md new file mode 100644 index 00000000..902fde8a --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-image.md @@ -0,0 +1,11 @@ +Plain relative image reference (the bulk-import authoring shape): + +![Triangle ABC](images/fig1.png) + +Same image with a scale URL parameter (already supported in the renderer): + +![Scaled diagram](images/fig2.png?scale=75) + +Media-protocol URL pointing at an R2-hosted asset: + +![](media:abc123?scale=50) diff --git a/web/scripts/__fixtures__/validate-md/valid-inline-math.md b/web/scripts/__fixtures__/validate-md/valid-inline-math.md new file mode 100644 index 00000000..8502b512 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-inline-math.md @@ -0,0 +1,13 @@ +Battery of inline math drawn from the SKMO archive. + +Plain inequality: $x \ne y$. + +Fraction: $\frac{a}{b}$. + +Degrees and radicals: $60^\circ$, $\sqrt{xy}$. + +Interval notation: $\langle a, b\rangle$ and $\langle b, \infty)$. + +Subscripted point names: $k_1(S_1, 3\,{\text{cm}})$. + +Powers of trig functions: $\tg^2\gamma \cdot \tg\frac{\gamma}{2}$. diff --git a/web/scripts/__fixtures__/validate-md/valid-link.md b/web/scripts/__fixtures__/validate-md/valid-link.md new file mode 100644 index 00000000..d85f5441 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-link.md @@ -0,0 +1,11 @@ +Link variants the renderer's url-allow-list accepts: + +External HTTPS: [Wikipedia](https://en.wikipedia.org/wiki/Mathematical_olympiad). + +Mailto: [Send a question](mailto:olympiada@example.com). + +Anchor in the same page: [Jump to the proof](#proof). + +Media protocol (R2-hosted asset): [PDF version](media:abc123). + +Domain-only link without protocol: [example.com](example.com). diff --git a/web/scripts/__fixtures__/validate-md/valid-list-bullet.md b/web/scripts/__fixtures__/validate-md/valid-list-bullet.md new file mode 100644 index 00000000..dc7478bb --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-bullet.md @@ -0,0 +1,6 @@ +Bullet list with mixed plain and math items: + +- First item in the list. +- Second item with an embedded formula $a^2 + b^2 = c^2$. +- Third item with several math symbols: $\alpha$, $\beta$, $\gamma$. +- Final item. diff --git a/web/scripts/__fixtures__/validate-md/valid-list-nested.md b/web/scripts/__fixtures__/validate-md/valid-list-nested.md new file mode 100644 index 00000000..838c27c6 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-nested.md @@ -0,0 +1,6 @@ +Nested list — outer bullet, inner numbered. Mirrors case-analysis structure in real solutions. + +- We split into two cases by the sign of $a$. + 1. If $a > 0$, then $\sqrt{a^2} = a$. + 2. If $a < 0$, then $\sqrt{a^2} = -a$. +- In both cases $\sqrt{a^2} = |a|$. diff --git a/web/scripts/__fixtures__/validate-md/valid-list-numbered.md b/web/scripts/__fixtures__/validate-md/valid-list-numbered.md new file mode 100644 index 00000000..0bc9fd30 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-numbered.md @@ -0,0 +1,6 @@ +Numbered list, common in problem solutions: + +1. First we show that $a > 0$. +2. Therefore $\sqrt{a}$ is well-defined. +3. We apply the AM-GM inequality. +4. Conclusion: equality holds if and only if $a = b$. diff --git a/web/scripts/__fixtures__/validate-md/valid-math-align.md b/web/scripts/__fixtures__/validate-md/valid-math-align.md new file mode 100644 index 00000000..ef4a345c --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-math-align.md @@ -0,0 +1,9 @@ +Aligned multi-line equation common in solutions: + +$$ +\begin{aligned} +(a + b)^2 &= a^2 + 2ab + b^2 \\ +(a - b)^2 &= a^2 - 2ab + b^2 \\ +(a + b)(a - b) &= a^2 - b^2 +\end{aligned} +$$ diff --git a/web/scripts/__fixtures__/validate-md/valid-math-mixed-flow.md b/web/scripts/__fixtures__/validate-md/valid-math-mixed-flow.md new file mode 100644 index 00000000..c0b35962 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-math-mixed-flow.md @@ -0,0 +1,5 @@ +For given positive numbers $x \ne y$ consider the means + +$$a=\frac{x+y}{2},\ g=\sqrt{xy},\ h=\frac{2xy}{x+y},\ k=\sqrt{\frac{x^2+y^2}{2}}.$$ + +(These are the arithmetic, geometric, harmonic, and quadratic means of $x$ and $y$.) diff --git a/web/scripts/__fixtures__/validate-md/valid-prose.md b/web/scripts/__fixtures__/validate-md/valid-prose.md new file mode 100644 index 00000000..570113ca --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-prose.md @@ -0,0 +1,3 @@ +A simple paragraph with **bold**, _italic_, and ~~strikethrough~~ text. + +A second paragraph follows with **_bold italic_** and a `code` span. diff --git a/web/scripts/__fixtures__/validate-md/valid-realistic-problem.md b/web/scripts/__fixtures__/validate-md/valid-realistic-problem.md new file mode 100644 index 00000000..79bf1223 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-realistic-problem.md @@ -0,0 +1,19 @@ +For given positive numbers $x \ne y$ consider the means + +$$a=\frac{x+y}{2},\ g=\sqrt{xy},\ h=\frac{2xy}{x+y},\ k=\sqrt{\frac{x^2+y^2}{2}}.$$ + +(These are the arithmetic, geometric, harmonic, and quadratic means of $x$ and $y$.) + +Prove that the following chain of inequalities holds: + +1. $h < g$, +2. $g < a$, +3. $a < k$. + +For intuition, the four means can be visualised on the figure below: + +![Four means of two numbers](images/means.png?scale=80) + +:::spoiler[Hint] +Start with $h < g$: it suffices to show $\frac{2xy}{x+y} < \sqrt{xy}$, which after clearing denominators reduces to $(\sqrt{x} - \sqrt{y})^2 > 0$. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-spoiler.md b/web/scripts/__fixtures__/validate-md/valid-spoiler.md new file mode 100644 index 00000000..50978700 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-spoiler.md @@ -0,0 +1,11 @@ +Problem statement asking the reader to find a closed form. + +:::spoiler[Hint] +Try the substitution $u = x^2$. After substituting you get + +$$\int u \, du = \frac{u^2}{2} + C.$$ + +Convert back to the original variable to obtain the result. +::: + +After solving, compare your work with the model solution. diff --git a/web/scripts/__fixtures__/validate-md/valid-table.md b/web/scripts/__fixtures__/validate-md/valid-table.md new file mode 100644 index 00000000..a8043d96 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-table.md @@ -0,0 +1,7 @@ +GFM table — needed for solutions per §3 format-coverage check. + +| Operation | Formula | Example | +| -------------- | ----------- | --------------- | +| addition | $a + b$ | $2 + 3 = 5$ | +| multiplication | $a \cdot b$ | $2 \cdot 3 = 6$ | +| exponentiation | $a^b$ | $2^3 = 8$ | diff --git a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap new file mode 100644 index 00000000..7db1b402 --- /dev/null +++ b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap @@ -0,0 +1,203 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`validate-md fixtures > invalid-katex-bad-environment.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: No such environment: notathing at position 7: \\begin{̲n̲o̲t̲a̲t̲h̲i̲n̲g̲}̲ x = 1 \\end{not…", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-empty-subscript.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Expected group after '_' at position 2: x_̲", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-in-list.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{a}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-in-spoiler.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Undefined control sequence: \\foo at position 1: \\̲f̲o̲o̲{u}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-in-table.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Undefined control sequence: \\unknowncmd at position 1: \\̲u̲n̲k̲n̲o̲w̲n̲c̲m̲d̲", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-mismatched-env.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Mismatch: \\begin{aligned} matched by \\end{cases} at position 24: …ligned} x &= 1 \\̲e̲n̲d̲{cases}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-missing-arg.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{a}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-nested.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Undefined control sequence: \\foo at position 7: \\sqrt{\\̲f̲o̲o̲{x}}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-stray-amp.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Expected 'EOF', got '&' at position 3: x &̲ y", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-unbalanced-braces.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{a{b}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-unclosed-brace.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Expected '}', got 'EOF' at end of input: x{", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-unclosed-env.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Expected & or \\\\ or \\cr or \\end at end of input: …aligned} x &= 1", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-unknown-cmd.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Undefined control sequence: \\foo at position 1: \\̲f̲o̲o̲{bar}", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-katex-unmatched-left.md 1`] = ` +{ + "errorFirstLine": "KaTeX parse error: Expected '\\right', got 'EOF' at end of input: \\left( x + y", + "stage": "katex", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > valid-blockquote.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-code.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-display-math.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-image.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-inline-math.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-link.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-list-bullet.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-list-nested.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-list-numbered.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-math-align.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-math-mixed-flow.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-prose.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-realistic-problem.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-spoiler.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-table.md 1`] = ` +{ + "verdict": "ok", +} +`; diff --git a/web/scripts/__tests__/validate-md.test.ts b/web/scripts/__tests__/validate-md.test.ts new file mode 100644 index 00000000..95b50e93 --- /dev/null +++ b/web/scripts/__tests__/validate-md.test.ts @@ -0,0 +1,106 @@ +/** + * Snapshot test for the `validate-md` fixtures. Each fixture is a markdown + * file under `scripts/__fixtures__/validate-md/`. The test asserts the + * verdict for `valid-*` (must succeed) and `invalid-*` (must fail), then + * snapshots a stable summary so future plugin upgrades cannot silently + * change behaviour without a snapshot diff to review. + */ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { describe, expect, it } from 'vitest' + +import { validateMarkdown } from '../../src/components/shared/components/rich-math-editor/utils/markdown-pipeline' + +/** Absolute directory containing this test file . */ +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)) + +/** Absolute directory containing all fixtures. */ +const FIXTURES_DIR = path.join(TEST_DIR, '..', '__fixtures__', 'validate-md') + +/** Sorted fixture filenames so test order is deterministic. */ +const fixtures = fs + .readdirSync(FIXTURES_DIR) + .filter((file) => file.endsWith('.md')) + .sort() + +/** + * Snapshot entry recorded for a fixture that passed validation. Verdict + * only — the rendered HTML is intentionally excluded so that trivial + * plugin-update formatting changes do not produce noisy snapshot diffs. + */ +type OkSnapshot = { + /** The discriminator */ + verdict: 'ok' +} + +/** + * Snapshot entry recorded for a fixture that failed validation. Captures + * stage + first line of the error so each failure mode has a distinct, + * reviewable snapshot. + */ +type FailSnapshot = { + /** The discriminator */ + verdict: 'fail' + /** Pipeline stage at which validation failed */ + stage: string + /** First line of the error message — KaTeX errors can be multi-line, only the headline is locked in */ + errorFirstLine: string +} + +/** + * Shape of one snapshot entry — discriminated union over success and + * failure cases. + */ +type SnapshotShape = OkSnapshot | FailSnapshot + +/** + * Reduces a {@link ValidationResult} to the minimal shape we lock in via + * snapshot. + * + * @param result - The result returned by `validateMarkdown` for a fixture. + * + * @returns A small object suitable for `toMatchSnapshot`. + */ +function summarize(result: Awaited>): SnapshotShape { + // Success path: verdict only + if (result.ok) { + return { verdict: 'ok' } + } + + // Failure path: take just the headline of the error message + const errorFirstLine = result.error.split('\n')[0] ?? '' + + // Wrap stage and headline into a fail-shaped entry + return { verdict: 'fail', stage: result.stage, errorFirstLine } +} + +describe('validate-md fixtures', () => { + // One test case per fixture; failures point at the offending file + for (const filename of fixtures) { + it(filename, async () => { + // Read the fixture content from disk + const text = fs.readFileSync(path.join(FIXTURES_DIR, filename), 'utf-8') + + // Run the validator + const result = await validateMarkdown(text) + + // Hard verdict assertions for the binary cases — a regression flips these even if the snapshot is regenerated + if (filename.startsWith('valid-')) { + expect( + result.ok, + result.ok ? '' : `expected ${filename} to validate, but failed: ${result.error}` + ).toBe(true) + } else if (filename.startsWith('invalid-')) { + expect( + result.ok, + result.ok ? `expected ${filename} to fail validation, but it passed` : '' + ).toBe(false) + } + + // Snapshot the summarised result regardless of branch above + expect(summarize(result)).toMatchSnapshot() + }) + } +}) diff --git a/web/src/components/shared/components/rich-math-editor/components/RichMathEditorRenderer.tsx b/web/src/components/shared/components/rich-math-editor/components/RichMathEditorRenderer.tsx index 413bb594..1f29df64 100644 --- a/web/src/components/shared/components/rich-math-editor/components/RichMathEditorRenderer.tsx +++ b/web/src/components/shared/components/rich-math-editor/components/RichMathEditorRenderer.tsx @@ -4,62 +4,16 @@ import type { ReactNode } from 'react' import Markdown, { type Components } from 'react-markdown' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' -import rehypeKatex from 'rehype-katex' -import rehypeRaw from 'rehype-raw' -import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' -import remarkBreaks from 'remark-breaks' -import remarkDirective from 'remark-directive' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' import { resolveMediaUrl } from '@/components/shared/utils/media-utils' -import { remarkSpoiler } from '../plugins/remark-spoiler' +import { + rehypePlugins as sharedRehypePlugins, + remarkPlugins as sharedRemarkPlugins, +} from '../utils/markdown-pipeline' import { preprocessDisplayMath } from '../utils/preprocessors' import { RichMathEditorSpoiler } from './RichMathEditorSpoiler' -/** - * Custom sanitization schema that extends the default GitHub schema. - * - * This allows our custom `` element while blocking XSS vectors - * like `