From 9644e70e6dbdc589713e41082c3bb8cace09c122 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 10 Aug 2026 20:02:40 +0300 Subject: [PATCH] fix(announcements): style every markdown element a body can contain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer accepts markdown; this renderer styled nine element types and left the rest at browser defaults, which on a dark card means an invisible `
`, a borderless GFM table, a quote indistinguishable from a paragraph and an `

` smaller than the body text above it. The fenced code block was worse than unstyled. A single `code` override applied inline code's pill — background, tiny type, no wrapping — to block code as well, inside a `
` with no styling of its own: a release note's
own install command rendered as a pill-backed run that pushed the modal wider
than the viewport instead of scrolling. Exactly the content an SDK
announcement is made of.

`pre` now owns the background and the horizontal scroll, and tells `code`
which context it is in through a small context. react-markdown v10 dropped
the `inline` prop, and the usual replacement — treating `language-*` as the
marker of a block — is wrong: a fence written without a language carries no
className and would fall back to inline styling. Descendant CSS cannot do it
either, since the inline pill sets its background through a `style`
attribute no ancestor class can override.

Also styled: tables (with the same scroll containment), blockquote, hr,
h4-h6, del, and images — `![alt](url)` is markdown, so that element already
existed whether or not anyone styled it; it now stays inside the card width
and loads lazily with `referrerPolicy="no-referrer"`, so a body's image
cannot report the reader's current URL to whatever host it points at.

No rehype-raw, no dangerouslySetInnerHTML: raw HTML in a body stays inert
text, and a test in this file now says so.
---
 src/components/announcements/Markdown.tsx     | 101 +++++++++++++++++-
 .../announcements/__tests__/Markdown.test.tsx |  86 +++++++++++++++
 2 files changed, 183 insertions(+), 4 deletions(-)
 create mode 100644 src/components/announcements/__tests__/Markdown.test.tsx

diff --git a/src/components/announcements/Markdown.tsx b/src/components/announcements/Markdown.tsx
index b2c9946..a2cd35d 100644
--- a/src/components/announcements/Markdown.tsx
+++ b/src/components/announcements/Markdown.tsx
@@ -1,3 +1,4 @@
+import { createContext, useContext } from 'react';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
 import type { ComponentPropsWithoutRef } from 'react';
@@ -6,6 +7,21 @@ export interface MarkdownProps {
   children: string;
 }
 
+/**
+ * Whether the `code` being rendered sits inside a fenced block.
+ *
+ * react-markdown v10 removed the `inline` prop its `code` component used to
+ * receive, and the usual replacement — "treat `className: language-*` as a
+ * block" — is wrong: a fence written without a language gets no className at
+ * all and would silently fall back to inline styling. The `pre` component
+ * below is the only thing that reliably knows, so it says so here.
+ *
+ * Descendant CSS would be the other way to do this (`pre code { … }`), but
+ * the inline-code style sets its background through a `style` attribute,
+ * which no class from an ancestor can override.
+ */
+const InCodeBlock = createContext(false);
+
 /**
  * The one place in this library that renders announcement body text. Deliberately
  * carries no `rehype-raw` and no `dangerouslySetInnerHTML` — react-markdown parses
@@ -14,6 +30,12 @@ export interface MarkdownProps {
  * element the DOM would execute. That is the whole security model here: there is
  * nothing to sanitise because raw HTML is never turned into elements in the first
  * place. Do not add `rehype-raw` to this file.
+ *
+ * Every element markdown (plus GFM) can produce is given a style. An element
+ * left out does not fail loudly — it renders at browser defaults, which on
+ * this dark card means an invisible `
`, a borderless table and a quote + * indistinguishable from a paragraph. The composer accepts all of it, so all + * of it is styled here. */ export function Markdown({ children }: MarkdownProps) { return ( @@ -24,15 +46,86 @@ export function Markdown({ children }: MarkdownProps) { h1: props =>

, h2: props =>

, h3: props =>

, + // h4 and below share h3's treatment: past the third level the + // distinction is decorative, and an announcement that nests deeper + // than that has a structure problem no type scale will fix. What + // matters is that they stop rendering at browser defaults, which + // made an h4 smaller than the body text around it. + h4: props =>

, + h5: props =>

, + h6: props =>
, p: props =>

, ul: props =>

    , ol: props =>
      , li: props =>
    1. , strong: props => , - code: props => ( - , + blockquote: props => ( +
      + ), + hr: props =>
      , + // The block owns the background and the scrolling; `code` inside it + // only carries the monospace face. A release note's install command + // routinely runs wider than this card, and a block that widens the + // card instead of scrolling inside it breaks the whole page layout. + pre: props => ( + +
      +            
      +          ),
      +          // A named, PascalCase function rather than an arrow: it calls a
      +          // hook, so it has to read as a component to both React and the
      +          // rules-of-hooks lint.
      +          code: function Code(props) {
      +            const inBlock = useContext(InCodeBlock);
      +            if (inBlock) {
      +              return ;
      +            }
      +            return (
      +              
      +            );
      +          },
      +          // GFM tables: the wrapper scrolls, for the same reason the code
      +          // block does — a comparison table of SDK versions is wider than a
      +          // 512px modal long before it is unreasonable to write.
      +          table: props => (
      +            
      + + + ), + th: props => ( +
      + ), + td: props => ( + + ), + // `![alt](url)` is markdown, not raw HTML, so this element exists + // whether or not it is styled — the src has always been the + // author's to choose. `no-referrer` keeps the reader's current URL + // from reaching whatever host that is; `lazy` keeps an image in a + // long body from competing with the text for the first paint. + img: ({ alt, ...props }: ComponentPropsWithoutRef<'img'>) => ( + {alt ), diff --git a/src/components/announcements/__tests__/Markdown.test.tsx b/src/components/announcements/__tests__/Markdown.test.tsx new file mode 100644 index 0000000..7527c2c --- /dev/null +++ b/src/components/announcements/__tests__/Markdown.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { Markdown } from '../Markdown.js'; + +/** + * Everything below is about markdown an author can actually type into the + * composer's Body field and that used to reach the reader unstyled — a code + * block wearing inline-code's pill, a GFM table with no rules, a quote + * indistinguishable from a paragraph. Class-name assertions are the honest + * test here: the defect is that no rule applied at all, so what has to be + * proven is that one does. + */ +describe('Markdown', () => { + it('renders a fenced code block as a scrollable block, not as inline code', () => { + const { container } = render( + {'```ts\nconst providers = createWalletApiProviders(base, { baseUrl, network });\n```'}, + ); + + const pre = container.querySelector('pre'); + expect(pre).toBeTruthy(); + // A long line must scroll inside the block instead of pushing the modal + // wider than the viewport. + expect(pre!.className).toContain('overflow-x-auto'); + // The block owns the background; the code inside it must not also wear + // the inline pill's, which is what a single `code` override produced. + expect(pre!.querySelector('code')!.getAttribute('style') ?? '').not.toContain('--bg-hover'); + }); + + it('renders a fenced block with no language the same way as one with a language', () => { + // The obvious shortcut — treat `className: 'language-*'` as "block" — is + // wrong: a fence without a language gets no className at all and would + // fall back to inline styling. + const { container } = render({'```\nnpm install\n```'}); + expect(container.querySelector('pre')!.querySelector('code')!.getAttribute('style') ?? '') + .not.toContain('--bg-hover'); + }); + + it('keeps the pill styling for inline code', () => { + const { container } = render({'Run `npm install` first.'}); + const code = container.querySelector('code')!; + expect(code.closest('pre')).toBeNull(); + expect(code.getAttribute('style') ?? '').toContain('--bg-hover'); + }); + + it('gives a gfm table its own rules rather than leaving it borderless', () => { + const { container } = render({'| Version | Status |\n| --- | --- |\n| 0.14.3 | current |'}); + + const table = container.querySelector('table'); + expect(table).toBeTruthy(); + expect(table!.className).toContain('w-full'); + expect(container.querySelector('th')!.className).toContain('border-b'); + // Wide tables scroll rather than widening the card. + expect(table!.parentElement!.className).toContain('overflow-x-auto'); + }); + + it('renders a blockquote as a quote instead of another paragraph', () => { + const { container } = render({'> Deprecated since 0.14.1.'}); + expect(container.querySelector('blockquote')!.className).toContain('border-l'); + }); + + it('renders a horizontal rule as a visible line', () => { + const { container } = render({'before\n\n---\n\nafter'}); + expect(container.querySelector('hr')!.className).toContain('h-px'); + }); + + it('keeps an image inside the card width', () => { + const { container } = render({'![A screenshot](https://cdn.example/shot.png)'}); + const img = container.querySelector('img')!; + expect(img.className).toContain('max-w-full'); + expect(img.getAttribute('alt')).toBe('A screenshot'); + }); + + it('styles headings below h3 instead of leaving them at browser defaults', () => { + const { container } = render({'#### Building a bot\n\n##### Details'}); + expect(container.querySelector('h4')!.className).toContain('font-semibold'); + expect(container.querySelector('h5')!.className).toContain('font-semibold'); + }); + + it('still renders raw html as inert text', () => { + // The security model this file documents, restated here so the components + // added around it cannot quietly reintroduce rehype-raw. + const { container } = render({'before after'}); + expect(container.querySelector('img[onerror]')).toBeNull(); + expect(container.textContent).toContain('after'); + }); +});