From ed838d609c74a9d3d6e7980448d600932b2be6e1 Mon Sep 17 00:00:00 2001 From: PatrikBak Date: Sun, 10 May 2026 16:01:24 +0200 Subject: [PATCH] Add markdown renderer extensions: directives, image params, preview - Image URL params (?width/?height/?inline/?scale) parsed by a shared utility, used by both the renderer (best-effort) and the markdown validator (strict) - :::list{style=...} container directive for custom list-marker styles and :quote[text] inline directive for locale-aware quotation marks - /[locale]/dev/renderer-preview dev-only visual catalog page exercising every renderer feature - All remark plugins now use unified's Transformer typing instead of local structural stubs - GitHub-style table CSS in .article--math; validator fixtures covering the new directive shapes Co-Authored-By: Claude Opus 4.7 --- web/public/dev-placeholders/block.svg | 4 + web/public/dev-placeholders/inline-1.svg | 4 + web/public/dev-placeholders/inline-2.svg | 4 + web/public/dev-placeholders/inline-A.svg | 4 + web/public/dev-placeholders/inline-equiv.svg | 4 + .../validate-md/invalid-image-bad-inline.md | 3 + .../invalid-image-non-numeric-dim.md | 3 + .../validate-md/invalid-image-only-height.md | 3 + .../validate-md/invalid-image-only-width.md | 3 + .../invalid-image-unknown-param.md | 3 + .../validate-md/invalid-image-zero-dim.md | 3 + .../validate-md/invalid-list-bad-style.md | 6 + .../validate-md/invalid-list-extra-attr.md | 6 + .../validate-md/invalid-list-no-style.md | 6 + .../validate-md/invalid-quote-empty.md | 3 + .../validate-md/invalid-quote-with-attrs.md | 3 + .../validate-md/valid-image-dimensions.md | 3 + .../valid-image-inline-with-dimensions.md | 3 + .../validate-md/valid-image-inline.md | 3 + .../validate-md/valid-list-bullet-explicit.md | 7 + .../valid-list-lower-alpha-parens.md | 7 + .../valid-list-lower-roman-parens.md | 7 + .../validate-md/valid-list-number-parens.md | 7 + .../valid-list-upper-alpha-parens.md | 7 + .../validate-md/valid-list-upper-roman.md | 7 + .../validate-md/valid-quote-in-list.md | 7 + .../validate-md/valid-quote-inline.md | 3 + .../__snapshots__/validate-md.test.ts.snap | 154 ++++++++++++ .../[locale]/dev/renderer-preview/page.tsx | 149 ++++++++++++ web/src/app/globals.css | 57 +++++ .../components/RichMathEditorRenderer.tsx | 116 +++++---- .../plugins/remark-image-params.ts | 46 ++++ .../plugins/remark-inline-quote.ts | 47 ++++ .../plugins/remark-list-style.ts | 130 ++++++++++ .../plugins/remark-spoiler.ts | 87 +++---- .../utils/__tests__/image-url-params.test.ts | 228 ++++++++++++++++++ .../utils/image-url-params.ts | 195 +++++++++++++++ .../utils/markdown-pipeline.ts | 17 +- web/src/proxy.ts | 21 +- 39 files changed, 1270 insertions(+), 100 deletions(-) create mode 100644 web/public/dev-placeholders/block.svg create mode 100644 web/public/dev-placeholders/inline-1.svg create mode 100644 web/public/dev-placeholders/inline-2.svg create mode 100644 web/public/dev-placeholders/inline-A.svg create mode 100644 web/public/dev-placeholders/inline-equiv.svg create mode 100644 web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-image-only-height.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-image-only-width.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-list-no-style.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-quote-empty.md create mode 100644 web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-image-dimensions.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-image-inline.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-list-number-parens.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-quote-in-list.md create mode 100644 web/scripts/__fixtures__/validate-md/valid-quote-inline.md create mode 100644 web/src/app/[locale]/dev/renderer-preview/page.tsx create mode 100644 web/src/components/shared/components/rich-math-editor/plugins/remark-image-params.ts create mode 100644 web/src/components/shared/components/rich-math-editor/plugins/remark-inline-quote.ts create mode 100644 web/src/components/shared/components/rich-math-editor/plugins/remark-list-style.ts create mode 100644 web/src/components/shared/components/rich-math-editor/utils/__tests__/image-url-params.test.ts create mode 100644 web/src/components/shared/components/rich-math-editor/utils/image-url-params.ts diff --git a/web/public/dev-placeholders/block.svg b/web/public/dev-placeholders/block.svg new file mode 100644 index 00000000..cf62addc --- /dev/null +++ b/web/public/dev-placeholders/block.svg @@ -0,0 +1,4 @@ + + + block placeholder · 800 × 400 + diff --git a/web/public/dev-placeholders/inline-1.svg b/web/public/dev-placeholders/inline-1.svg new file mode 100644 index 00000000..20902743 --- /dev/null +++ b/web/public/dev-placeholders/inline-1.svg @@ -0,0 +1,4 @@ + + + 1 + diff --git a/web/public/dev-placeholders/inline-2.svg b/web/public/dev-placeholders/inline-2.svg new file mode 100644 index 00000000..ee34fb0c --- /dev/null +++ b/web/public/dev-placeholders/inline-2.svg @@ -0,0 +1,4 @@ + + + 2 + diff --git a/web/public/dev-placeholders/inline-A.svg b/web/public/dev-placeholders/inline-A.svg new file mode 100644 index 00000000..1d459ce7 --- /dev/null +++ b/web/public/dev-placeholders/inline-A.svg @@ -0,0 +1,4 @@ + + + A + diff --git a/web/public/dev-placeholders/inline-equiv.svg b/web/public/dev-placeholders/inline-equiv.svg new file mode 100644 index 00000000..2e579aea --- /dev/null +++ b/web/public/dev-placeholders/inline-equiv.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md b/web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md new file mode 100644 index 00000000..c99897e9 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-image-bad-inline.md @@ -0,0 +1,3 @@ +Non-boolean value for the `inline` parameter must surface as a validation error. + +![Tiny diagram](images/eq.svg?inline=yes) diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md b/web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md new file mode 100644 index 00000000..809ce169 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-image-non-numeric-dim.md @@ -0,0 +1,3 @@ +Non-numeric value for a dimension parameter must surface as a validation error. + +![Triangle ABC](images/fig1.svg?width=abc&height=200) diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-only-height.md b/web/scripts/__fixtures__/validate-md/invalid-image-only-height.md new file mode 100644 index 00000000..db479dfb --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-image-only-height.md @@ -0,0 +1,3 @@ +Image URL carrying only `height` (no matching `width`) must surface as a validation error. + +![Triangle ABC](images/fig1.svg?height=300) diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-only-width.md b/web/scripts/__fixtures__/validate-md/invalid-image-only-width.md new file mode 100644 index 00000000..b52e3d62 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-image-only-width.md @@ -0,0 +1,3 @@ +Image URL carrying only `width` (no matching `height`) must surface as a validation error — partial dimensions defeat layout reservation. + +![Triangle ABC](images/fig1.svg?width=400) diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md b/web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md new file mode 100644 index 00000000..fc4223cc --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-image-unknown-param.md @@ -0,0 +1,3 @@ +Unknown query parameter on an image URL — typo guard against e.g. `widht` instead of `width`. + +![Triangle ABC](images/fig1.svg?widht=400&height=300) diff --git a/web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md b/web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md new file mode 100644 index 00000000..ddd9043c --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-image-zero-dim.md @@ -0,0 +1,3 @@ +Zero or negative dimension value must surface as a validation error — `next/image` rejects non-positive sizes. + +![Triangle ABC](images/fig1.svg?width=0&height=200) diff --git a/web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md b/web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md new file mode 100644 index 00000000..46384ce7 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-list-bad-style.md @@ -0,0 +1,6 @@ +Unknown style identifier on the list directive must surface as a validation error. + +:::list{style=mystery} +- First item. +- Second item. +::: diff --git a/web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md b/web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md new file mode 100644 index 00000000..2767fae2 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-list-extra-attr.md @@ -0,0 +1,6 @@ +Unknown extra attribute alongside the valid `style` must surface as a validation error so typos cannot pass silently. + +:::list{style=lower-alpha-parens foo=bar} +- First item. +- Second item. +::: diff --git a/web/scripts/__fixtures__/validate-md/invalid-list-no-style.md b/web/scripts/__fixtures__/validate-md/invalid-list-no-style.md new file mode 100644 index 00000000..777ef2b6 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-list-no-style.md @@ -0,0 +1,6 @@ +A `:::list` directive without the required `style` attribute must surface as a validation error. + +:::list +- First item. +- Second item. +::: diff --git a/web/scripts/__fixtures__/validate-md/invalid-quote-empty.md b/web/scripts/__fixtures__/validate-md/invalid-quote-empty.md new file mode 100644 index 00000000..d25ab0c7 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-quote-empty.md @@ -0,0 +1,3 @@ +An empty `:quote[]` directive must surface as a validation error — there's nothing for the renderer to wrap. + +The committee replied :quote[] without further explanation. diff --git a/web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md b/web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md new file mode 100644 index 00000000..e6294a49 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/invalid-quote-with-attrs.md @@ -0,0 +1,3 @@ +The inline `:quote` directive does not accept any attributes — passing one must surface as a validation error. + +The committee replied :quote[your proof is correct]{class=highlighted} without further comment. diff --git a/web/scripts/__fixtures__/validate-md/valid-image-dimensions.md b/web/scripts/__fixtures__/validate-md/valid-image-dimensions.md new file mode 100644 index 00000000..b2d7242b --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-image-dimensions.md @@ -0,0 +1,3 @@ +Block image with intrinsic dimensions on the URL — the renderer reserves layout up front so there is no CLS jump when the file arrives: + +![Triangle ABC with cevians](images/fig1.svg?width=480&height=320) diff --git a/web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md b/web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md new file mode 100644 index 00000000..72896343 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-image-inline-with-dimensions.md @@ -0,0 +1,3 @@ +Inline image carrying both intrinsic dimensions and the inline flag — the most common shape for problem images that sit mid-sentence: + +We use the symbol ![equals](images/eq.svg?inline=true&width=24&height=16) to denote equivalence in the rest of the proof. diff --git a/web/scripts/__fixtures__/validate-md/valid-image-inline.md b/web/scripts/__fixtures__/validate-md/valid-image-inline.md new file mode 100644 index 00000000..49ec21f1 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-image-inline.md @@ -0,0 +1,3 @@ +Inline image flowing with the surrounding sentence text — useful for tiny diagrams referenced mid-prose: + +The angle marked ![angle](images/angle-mark.svg?inline=true) appears twice in the configuration. diff --git a/web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md b/web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md new file mode 100644 index 00000000..486820f3 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-bullet-explicit.md @@ -0,0 +1,7 @@ +Explicit bullet style is accepted by the directive even though the bare `- ` form would render identically — the directive accepts every style identifier, including the two that have a native markdown shorthand. + +:::list{style=bullet} +- First observation about the configuration. +- Second observation about the configuration. +- Third observation about the configuration. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md new file mode 100644 index 00000000..d1177232 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-lower-alpha-parens.md @@ -0,0 +1,7 @@ +Lower-case alphabetic markers with parentheses — the most common multi-part question style: + +:::list{style=lower-alpha-parens} +- Find the minimum of $f(x) = x^2 + \frac{1}{x}$ on $(0, \infty)$. +- Prove the inequality is strict. +- Determine when equality is approached. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md new file mode 100644 index 00000000..95fc8d7a --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-lower-roman-parens.md @@ -0,0 +1,7 @@ +Lower-case roman numerals with parentheses: + +:::list{style=lower-roman-parens} +- Consider the case $n = 1$. +- Now suppose $n > 1$ and the claim holds for $n - 1$. +- Combine the two to conclude. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-list-number-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-number-parens.md new file mode 100644 index 00000000..89d68169 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-number-parens.md @@ -0,0 +1,7 @@ +Numbered list with parenthesised digits — common in problem statements that step through cases: + +:::list{style=number-parens} +- First we show that $a > 0$. +- Therefore $\sqrt{a}$ is well-defined. +- We apply the AM-GM inequality. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md b/web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md new file mode 100644 index 00000000..a0e159f1 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-upper-alpha-parens.md @@ -0,0 +1,7 @@ +Upper-case alphabetic markers with parentheses: + +:::list{style=upper-alpha-parens} +- Triangle $ABC$ is acute. +- Triangle $ABC$ is right-angled at $C$. +- Triangle $ABC$ is obtuse at one vertex. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md b/web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md new file mode 100644 index 00000000..711ea7e1 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-list-upper-roman.md @@ -0,0 +1,7 @@ +Upper-case roman numerals followed by a colon — used to label major cases: + +:::list{style=upper-roman} +- All roots are real and distinct. +- Exactly two roots coincide. +- All three roots coincide. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-quote-in-list.md b/web/scripts/__fixtures__/validate-md/valid-quote-in-list.md new file mode 100644 index 00000000..9db74d67 --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-quote-in-list.md @@ -0,0 +1,7 @@ +Inline quote nested inside a styled list — common interaction in problem statements that quote source material per case: + +:::list{style=lower-alpha-parens} +- The first source claims :quote[every prime greater than 3 has the form $6k \pm 1$]. +- The second source claims :quote[the sum of the first $n$ odd numbers equals $n^2$]. +- Reconcile the two claims with a direct argument. +::: diff --git a/web/scripts/__fixtures__/validate-md/valid-quote-inline.md b/web/scripts/__fixtures__/validate-md/valid-quote-inline.md new file mode 100644 index 00000000..210db21d --- /dev/null +++ b/web/scripts/__fixtures__/validate-md/valid-quote-inline.md @@ -0,0 +1,3 @@ +Inline quote rendered as `...` — mirrors the AST `QuoteText` block produced by `\uv{}` in SK/CS source: + +The committee's reply was simply :quote[your proof is correct, but unnecessarily long], which left the contestant unsure how to revise. diff --git a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap index 7db1b402..e86a34ae 100644 --- a/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap +++ b/web/scripts/__tests__/__snapshots__/validate-md.test.ts.snap @@ -1,5 +1,53 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`validate-md fixtures > invalid-image-bad-inline.md 1`] = ` +{ + "errorFirstLine": "Image URL has invalid inline="yes" (expected "true" or "false")", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-image-non-numeric-dim.md 1`] = ` +{ + "errorFirstLine": "Image URL has invalid width="abc" (expected a positive integer)", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-image-only-height.md 1`] = ` +{ + "errorFirstLine": "Image URL has only one of width/height — both must be specified together", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-image-only-width.md 1`] = ` +{ + "errorFirstLine": "Image URL has only one of width/height — both must be specified together", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-image-unknown-param.md 1`] = ` +{ + "errorFirstLine": "Image URL has unknown parameter "widht"", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-image-zero-dim.md 1`] = ` +{ + "errorFirstLine": "Image URL has invalid width="0" (expected a positive integer)", + "stage": "parse", + "verdict": "fail", +} +`; + 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…", @@ -112,6 +160,46 @@ exports[`validate-md fixtures > invalid-katex-unmatched-left.md 1`] = ` } `; +exports[`validate-md fixtures > invalid-list-bad-style.md 1`] = ` +{ + "errorFirstLine": "Unknown list style "mystery" on :::list directive (expected one of: bullet, number-dot, number-parens, lower-roman-parens, upper-roman, lower-alpha-parens, upper-alpha-parens)", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-list-extra-attr.md 1`] = ` +{ + "errorFirstLine": "Unknown attribute(s) on :::list directive: foo", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-list-no-style.md 1`] = ` +{ + "errorFirstLine": "Missing \`style\` attribute on :::list directive (expected one of: bullet, number-dot, number-parens, lower-roman-parens, upper-roman, lower-alpha-parens, upper-alpha-parens)", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-quote-empty.md 1`] = ` +{ + "errorFirstLine": ":quote directive has empty content", + "stage": "parse", + "verdict": "fail", +} +`; + +exports[`validate-md fixtures > invalid-quote-with-attrs.md 1`] = ` +{ + "errorFirstLine": ":quote directive does not accept attributes", + "stage": "parse", + "verdict": "fail", +} +`; + exports[`validate-md fixtures > valid-blockquote.md 1`] = ` { "verdict": "ok", @@ -136,6 +224,24 @@ exports[`validate-md fixtures > valid-image.md 1`] = ` } `; +exports[`validate-md fixtures > valid-image-dimensions.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-image-inline.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-image-inline-with-dimensions.md 1`] = ` +{ + "verdict": "ok", +} +`; + exports[`validate-md fixtures > valid-inline-math.md 1`] = ` { "verdict": "ok", @@ -154,18 +260,54 @@ exports[`validate-md fixtures > valid-list-bullet.md 1`] = ` } `; +exports[`validate-md fixtures > valid-list-bullet-explicit.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-list-lower-alpha-parens.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-list-lower-roman-parens.md 1`] = ` +{ + "verdict": "ok", +} +`; + exports[`validate-md fixtures > valid-list-nested.md 1`] = ` { "verdict": "ok", } `; +exports[`validate-md fixtures > valid-list-number-parens.md 1`] = ` +{ + "verdict": "ok", +} +`; + exports[`validate-md fixtures > valid-list-numbered.md 1`] = ` { "verdict": "ok", } `; +exports[`validate-md fixtures > valid-list-upper-alpha-parens.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-list-upper-roman.md 1`] = ` +{ + "verdict": "ok", +} +`; + exports[`validate-md fixtures > valid-math-align.md 1`] = ` { "verdict": "ok", @@ -184,6 +326,18 @@ exports[`validate-md fixtures > valid-prose.md 1`] = ` } `; +exports[`validate-md fixtures > valid-quote-in-list.md 1`] = ` +{ + "verdict": "ok", +} +`; + +exports[`validate-md fixtures > valid-quote-inline.md 1`] = ` +{ + "verdict": "ok", +} +`; + exports[`validate-md fixtures > valid-realistic-problem.md 1`] = ` { "verdict": "ok", diff --git a/web/src/app/[locale]/dev/renderer-preview/page.tsx b/web/src/app/[locale]/dev/renderer-preview/page.tsx new file mode 100644 index 00000000..c475b90e --- /dev/null +++ b/web/src/app/[locale]/dev/renderer-preview/page.tsx @@ -0,0 +1,149 @@ +import { RichMathEditorRenderer } from '@/components/shared/components/rich-math-editor/components/RichMathEditorRenderer' + +/** + * One large markdown document that exercises every shape the renderer handles. + * Used by the dev-only preview page below to surface visual regressions when + * changing pipeline plugins, sanitize rules, or rendering components. + */ +const SAMPLE_MARKDOWN = ` +# Renderer catalog + +A single document exercising every shape the markdown renderer supports — text styling, math, lists (default and custom-marker), spoilers, quotes, code, tables, images, and the interactions between them. Mount it whenever pipeline plugins, the sanitize schema, or render components change to spot regressions visually. + +## Text and inline math + +A paragraph with **bold**, *italic*, inline math $x^2 + y^2 = z^2$, and a [link to example](https://example.com). + +## Display math + +$$ +\\int_0^1 x^2 \\, dx = \\frac{1}{3} +$$ + +## Default lists + +A default bullet list: + +- First bullet item. +- Second bullet item. +- Third bullet item. + +A default numbered list: + +1. First numbered item. +2. Second numbered item. +3. Third numbered item. + +## Custom list styles + +Number with parentheses — \`(1) (2) (3)\`: + +:::list{style=number-parens} +- First case: $a > 0$. +- Second case: $a = 0$. +- Third case: $a < 0$. +::: + +Lower roman with parentheses — \`(i) (ii) (iii)\`: + +:::list{style=lower-roman-parens} +- Base case $n = 1$. +- Inductive step $n \\to n + 1$. +- Conclusion. +::: + +Upper roman with colon suffix — \`I: II: III:\`: + +:::list{style=upper-roman} +- All three roots are real and distinct. +- Exactly two roots coincide. +- All three roots coincide. +::: + +Lower alpha with parentheses — \`(a) (b) (c)\`: + +:::list{style=lower-alpha-parens} +- Find the minimum of $f(x) = x^2 + \\frac{1}{x}$. +- Prove the inequality is strict. +- Determine when equality is approached. +::: + +Upper alpha with parentheses — \`(A) (B) (C)\`: + +:::list{style=upper-alpha-parens} +- Triangle $ABC$ is acute. +- Triangle $ABC$ is right-angled. +- Triangle $ABC$ is obtuse. +::: + +## Spoiler + +:::spoiler[Click to reveal] +Hidden hint with math: try $u = x^2$. +::: + +## Block quote + +> Quoted excerpt that spans the full width and renders with a left border. + +## Inline quote + +The committee's reply was simply :quote[your proof is correct, but unnecessarily long], which left the contestant unsure how to revise. The browser renders this with locale-aware quotation marks via the native \`\` element. + +## Code + +\`\`\`python +def fibonacci(n): + return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2) +\`\`\` + +## Table + +| Style | Marker | +| ----- | ------ | +| bullet | • | +| number-dot | 1. | + +## Images + +A block image without parameters — sized at runtime, no layout reservation: + +![Block placeholder](/dev-placeholders/block.svg) + +A block image carrying intrinsic dimensions on the URL — \`next/image\` reserves layout up front so there is no CLS jump while the asset loads: + +![Block with dimensions](/dev-placeholders/block.svg?width=800&height=400) + +An inline image flowing with surrounding text via \`?inline=true\` — the angle marked ![angle](/dev-placeholders/inline-A.svg?inline=true) appears mid-sentence without breaking onto its own line. + +An inline image carrying both inline and dimension flags — we use the symbol ![equiv](/dev-placeholders/inline-equiv.svg?inline=true&width=32&height=32) to denote equivalence in the rest of the proof. + +## Compositions + +Inline quote inside a custom-marker list: + +:::list{style=lower-alpha-parens} +- The first source claims :quote[every prime greater than 3 has the form $6k \\pm 1$]. +- The second source claims :quote[the sum of the first $n$ odd numbers equals $n^2$]. +- Reconcile the two claims. +::: + +Inline images inside a custom-marker list: + +:::list{style=upper-roman} +- The first configuration is shown here: ![first](/dev-placeholders/inline-1.svg?inline=true&width=24&height=24). +- The second configuration is shown here: ![second](/dev-placeholders/inline-2.svg?inline=true&width=24&height=24). +- Compare the two configurations. +::: +` + +/** + * Dev-only visual catalog of the markdown renderer. + */ +export default function RendererPreviewPage() { + return ( +
+ +
+ ) +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 7f31e74a..576dbb12 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -254,6 +254,46 @@ input[type='checkbox']:focus:not(:focus-visible) { content: '(' counter(list-item, lower-alpha) ')\00a0\00a0'; } +/* + Custom list marker styles emitted by the :::list{style=...} directive. + Class names match the kebab-case AST style identifiers verbatim. + bullet and number-dot get no override — they use the default
    /
      markers. +*/ +.list-style-number-parens { + list-style-type: decimal; +} +.list-style-number-parens > li::marker { + content: '(' counter(list-item, decimal) ')\00a0\00a0'; +} + +.list-style-lower-roman-parens { + list-style-type: lower-roman; +} +.list-style-lower-roman-parens > li::marker { + content: '(' counter(list-item, lower-roman) ')\00a0\00a0'; +} + +.list-style-upper-roman { + list-style-type: upper-roman; +} +.list-style-upper-roman > li::marker { + content: counter(list-item, upper-roman) ':\00a0\00a0'; +} + +.list-style-lower-alpha-parens { + list-style-type: lower-alpha; +} +.list-style-lower-alpha-parens > li::marker { + content: '(' counter(list-item, lower-alpha) ')\00a0\00a0'; +} + +.list-style-upper-alpha-parens { + list-style-type: upper-alpha; +} +.list-style-upper-alpha-parens > li::marker { + content: '(' counter(list-item, upper-alpha) ')\00a0\00a0'; +} + /* Body of math handouts: serif for TeX-like feel, with fluid size */ .article--math { font-family: Georgia, 'Times New Roman', ui-serif, serif; @@ -402,6 +442,23 @@ input[type='checkbox']:focus:not(:focus-visible) { margin-top: 0.2em; } +/* Tables — math style: full grid, bold header, no row fills */ +.article--math table { + margin: 0.9em 0; + border-collapse: collapse; + font-size: 0.96em; +} +.article--math th, +.article--math td { + padding: 6px 13px; + text-align: left; + vertical-align: top; + border: 1px solid var(--color-surface-hover); +} +.article--math thead th { + font-weight: 600; +} + /* Display math on small screens: avoid overflow clipping */ @media (max-width: 640px) { .katex-display { 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 1f29df64..54155183 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 @@ -7,6 +7,7 @@ import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' import { resolveMediaUrl } from '@/components/shared/utils/media-utils' +import { parseImageUrl } from '../utils/image-url-params' import { rehypePlugins as sharedRehypePlugins, remarkPlugins as sharedRemarkPlugins, @@ -30,6 +31,25 @@ type CustomComponents = Components & { spoiler?: ({ children }: { children: ReactNode }) => ReactNode } +/** + * Resolves the className for a list element, honoring custom marker styles + * passed via the `list-style-