From 59f17b48905501718c6087e70371e824198a6153 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 10 Aug 2026 21:45:11 +0300 Subject: [PATCH] feat(announcements): honour the line breaks an author actually typed --- package-lock.json | 35 +++++++++++++++++++ package.json | 1 + src/components/announcements/Markdown.tsx | 11 +++++- .../announcements/__tests__/Markdown.test.tsx | 24 +++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b9af023..59b35e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react-dropzone": "^14.4.1", "react-easy-crop": "^6.2.2", "react-markdown": "^10.1.0", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1" }, "devDependencies": { @@ -1792,6 +1793,7 @@ "version": "19.2.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2374,6 +2376,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-array": { @@ -3650,6 +3653,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -4649,6 +4666,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4658,6 +4676,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -4841,6 +4860,21 @@ "redux": "^5.0.0" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -5000,6 +5034,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/semver": { diff --git a/package.json b/package.json index 23f6b86..1491900 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "react-dropzone": "^14.4.1", "react-easy-crop": "^6.2.2", "react-markdown": "^10.1.0", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1" }, "peerDependencies": { diff --git a/src/components/announcements/Markdown.tsx b/src/components/announcements/Markdown.tsx index a2cd35d..a09258f 100644 --- a/src/components/announcements/Markdown.tsx +++ b/src/components/announcements/Markdown.tsx @@ -1,5 +1,6 @@ import { createContext, useContext } from 'react'; import ReactMarkdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; import type { ComponentPropsWithoutRef } from 'react'; @@ -41,7 +42,15 @@ export function Markdown({ children }: MarkdownProps) { return (

, h2: props =>

, diff --git a/src/components/announcements/__tests__/Markdown.test.tsx b/src/components/announcements/__tests__/Markdown.test.tsx index 7527c2c..989a119 100644 --- a/src/components/announcements/__tests__/Markdown.test.tsx +++ b/src/components/announcements/__tests__/Markdown.test.tsx @@ -42,6 +42,30 @@ describe('Markdown', () => { expect(code.getAttribute('style') ?? '').toContain('--bg-hover'); }); + // The composer's Body is a plain textarea, so an author presses Enter and + // expects a line break. Markdown's own rule — a single newline is a space, + // only a blank line or two trailing spaces break the line — silently ran + // their lines together, which is exactly what happened to a moderation + // note typed on two lines. Chat and comment fields everywhere (GitHub's + // included) resolve this the same way: honour the newline that was typed. + it('turns a single newline into a line break, as the textarea it was typed in shows it', () => { + const { container } = render({'Test Test Test Test Test\nTestTestTestTestTest'}); + expect(container.querySelector('br')).toBeTruthy(); + }); + + it('still starts a new paragraph on a blank line', () => { + const { container } = render({'one\n\ntwo'}); + expect(container.querySelectorAll('p')).toHaveLength(2); + }); + + it('leaves the newlines inside a code block exactly as written', () => { + // A
injected into a code block would change the code itself. + const { container } = render({'```\nnpm install\nnpm run build\n```'}); + const pre = container.querySelector('pre')!; + expect(pre.querySelector('br')).toBeNull(); + expect(pre.textContent).toContain('npm install\nnpm run build'); + }); + it('gives a gfm table its own rules rather than leaving it borderless', () => { const { container } = render({'| Version | Status |\n| --- | --- |\n| 0.14.3 | current |'});