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
101 changes: 97 additions & 4 deletions src/components/announcements/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createContext, useContext } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { ComponentPropsWithoutRef } from 'react';
Expand All @@ -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
Expand All @@ -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 `<hr>`, 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 (
Expand All @@ -24,15 +46,86 @@ export function Markdown({ children }: MarkdownProps) {
h1: props => <h1 className="text-lg font-semibold mt-4 mb-2" style={{ color: 'var(--text-primary)' }} {...props} />,
h2: props => <h2 className="text-base font-semibold mt-4 mb-2" style={{ color: 'var(--text-primary)' }} {...props} />,
h3: props => <h3 className="text-sm font-semibold mt-3 mb-1.5" style={{ color: 'var(--text-primary)' }} {...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 => <h4 className="text-sm font-semibold mt-3 mb-1.5" style={{ color: 'var(--text-primary)' }} {...props} />,
h5: props => <h5 className="text-sm font-semibold mt-3 mb-1.5" style={{ color: 'var(--text-primary)' }} {...props} />,
h6: props => <h6 className="text-sm font-semibold mt-3 mb-1.5" style={{ color: 'var(--text-primary)' }} {...props} />,
p: props => <p className="mb-2 last:mb-0" {...props} />,
ul: props => <ul className="list-disc pl-5 mb-2 space-y-1" {...props} />,
ol: props => <ol className="list-decimal pl-5 mb-2 space-y-1" {...props} />,
li: props => <li {...props} />,
strong: props => <strong className="font-semibold" style={{ color: 'var(--text-primary)' }} {...props} />,
code: props => (
<code
className="px-1 py-0.5 rounded text-xs"
style={{ background: 'var(--bg-hover)', fontFamily: 'var(--font-mono)' }}
del: props => <del className="line-through opacity-70" {...props} />,
blockquote: props => (
<blockquote
className="border-l-2 pl-3 my-2 italic"
style={{ borderColor: 'var(--border)' }}
{...props}
/>
),
hr: props => <hr className="my-4 border-0 h-px" style={{ background: 'var(--border)' }} {...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 => (
<InCodeBlock.Provider value={true}>
<pre
className="rounded-lg p-3 mb-2 overflow-x-auto text-xs"
style={{ background: 'var(--bg-hover)' }}
{...props}
/>
</InCodeBlock.Provider>
),
// 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 <code style={{ fontFamily: 'var(--font-mono)' }} {...props} />;
}
return (
<code
className="px-1 py-0.5 rounded text-xs"
style={{ background: 'var(--bg-hover)', fontFamily: 'var(--font-mono)' }}
{...props}
/>
);
},
// 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 => (
<div className="overflow-x-auto mb-2">
<table className="w-full text-xs border-collapse" {...props} />
</div>
),
th: props => (
<th
className="text-left font-semibold px-2 py-1 border-b"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
{...props}
/>
),
td: props => (
<td className="px-2 py-1 border-b align-top" style={{ borderColor: 'var(--border)' }} {...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'>) => (
<img
alt={alt ?? ''}
className="max-w-full h-auto rounded-lg my-2"
loading="lazy"
referrerPolicy="no-referrer"
{...props}
/>
),
Expand Down
86 changes: 86 additions & 0 deletions src/components/announcements/__tests__/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Markdown>{'```ts\nconst providers = createWalletApiProviders(base, { baseUrl, network });\n```'}</Markdown>,
);

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(<Markdown>{'```\nnpm install\n```'}</Markdown>);
expect(container.querySelector('pre')!.querySelector('code')!.getAttribute('style') ?? '')
.not.toContain('--bg-hover');
});

it('keeps the pill styling for inline code', () => {
const { container } = render(<Markdown>{'Run `npm install` first.'}</Markdown>);
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(<Markdown>{'| Version | Status |\n| --- | --- |\n| 0.14.3 | current |'}</Markdown>);

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(<Markdown>{'> Deprecated since 0.14.1.'}</Markdown>);
expect(container.querySelector('blockquote')!.className).toContain('border-l');
});

it('renders a horizontal rule as a visible line', () => {
const { container } = render(<Markdown>{'before\n\n---\n\nafter'}</Markdown>);
expect(container.querySelector('hr')!.className).toContain('h-px');
});

it('keeps an image inside the card width', () => {
const { container } = render(<Markdown>{'![A screenshot](https://cdn.example/shot.png)'}</Markdown>);
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(<Markdown>{'#### Building a bot\n\n##### Details'}</Markdown>);
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(<Markdown>{'before <img src=x onerror="alert(1)"> after'}</Markdown>);
expect(container.querySelector('img[onerror]')).toBeNull();
expect(container.textContent).toContain('after');
});
});
Loading