mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
fix: render node-update changelog notes as formatted markdown (#1474)
The Changelog tab in the Node Updates sheet rendered GitHub release notes as raw markdown inside a preformatted block, so headings, bullet lists, and issue/commit links showed as literal markup and were hard to read. Render the notes with a small react-markdown wrapper styled to the design tokens (raw HTML is intentionally not rendered, keeping it safe). Also: - Replace the bare loading spinner with a content-shaped skeleton. - Add a graceful empty state when no notes are available, with a link to the online changelog. - Add a "View on Sencho" link next to "View on GitHub". - Stop the release-notes fetch from re-firing on every failure by tracking whether a fetch has settled, so a null result lands on the empty state instead of looping; Recheck resets it to force a fresh fetch.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import Markdown, { type Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Renders a markdown string (e.g. GitHub release notes) as styled React
|
||||
* elements. Raw HTML in the source is intentionally not rendered, so the
|
||||
* output is safe from injected markup.
|
||||
*/
|
||||
const components: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-base font-semibold text-stat-value mt-5 first:mt-0 mb-2">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-sm font-semibold text-stat-value mt-5 first:mt-0 mb-2">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-stat-subtitle mt-4 first:mt-0 mb-1.5">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-sm text-stat-value leading-relaxed my-2 first:mt-0 last:mb-0">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1 my-2 text-sm text-stat-value">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1 my-2 text-sm text-stat-value">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="font-mono text-[0.8125rem] rounded bg-card-border/40 px-1 py-0.5 text-stat-value">{children}</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
// The fenced block owns its frame; neutralize the inline-code pill on the
|
||||
// inner <code> so it does not paint a background inside the block.
|
||||
<pre className="font-mono text-xs rounded-md border border-card-border bg-card-border/20 p-3 overflow-x-auto my-2 [&>code]:bg-transparent [&>code]:p-0 [&>code]:rounded-none">{children}</pre>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-semibold text-stat-value">{children}</strong>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-card-border pl-3 text-stat-subtitle italic my-2">{children}</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-card-border/60 my-4" />,
|
||||
};
|
||||
|
||||
export function MarkdownContent({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MarkdownContent } from '../MarkdownContent';
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
it('does not render raw HTML from the source as live markup', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'Hello <img src=x onerror="alert(1)"> <script>alert(2)</script> world'}</MarkdownContent>,
|
||||
);
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders standard markdown structures', () => {
|
||||
const { container, getByRole } = render(
|
||||
<MarkdownContent>{'# Title\n\n* one\n* two\n\n[link](https://example.com)'}</MarkdownContent>,
|
||||
);
|
||||
expect(getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2);
|
||||
expect(getByRole('link', { name: 'link' })).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user