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 `
,
+ 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 => (
+
+ ),
+ // `` 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'>) => (
+
),
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({''} );
+ 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');
+ });
+});