feat: RichText component for safe PocketBase HTML rendering
Add html-react-parser-backed RichText component that converts HTML strings from PocketBase rich-text fields into React elements without dangerouslySetInnerHTML. Replace raw <p> render in IntroSection and BioSection, and drop the invalid slug filters those collections lacked.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { RichText } from './ui/RichText';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { RichText } from './RichText';
|
||||
|
||||
describe('RichText', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders a paragraph from <p> tag', () => {
|
||||
render(<RichText html="<p>Hello world</p>" />);
|
||||
expect(screen.getByText('Hello world').tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('renders bold text from <strong> tag', () => {
|
||||
render(<RichText html="<strong>Bold</strong>" />);
|
||||
expect(screen.getByText('Bold').tagName).toBe('STRONG');
|
||||
});
|
||||
|
||||
it('renders a link from <a> tag', () => {
|
||||
render(<RichText html='<a href="https://example.com">Link</a>' />);
|
||||
const link = screen.getByRole('link', { name: 'Link' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
});
|
||||
|
||||
it('renders nested tags', () => {
|
||||
render(<RichText html="<p>Text with <em>emphasis</em></p>" />);
|
||||
expect(screen.getByText('emphasis').tagName).toBe('EM');
|
||||
});
|
||||
|
||||
it('renders nothing for empty string', () => {
|
||||
const { container } = render(<RichText html="" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders multiple sibling elements', () => {
|
||||
render(<RichText html="<p>First</p><p>Second</p>" />);
|
||||
expect(screen.getByText('First')).toBeInTheDocument();
|
||||
expect(screen.getByText('Second')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('className passthrough', () => {
|
||||
it('applies className to the wrapper', () => {
|
||||
const { container } = render(<RichText html="<p>text</p>" className="prose" />);
|
||||
expect(container.firstChild).toHaveClass('prose');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import parse from 'html-react-parser';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* HTML string from PocketBase rich-text editor
|
||||
*/
|
||||
html: string;
|
||||
/**
|
||||
* CSS classes applied to the wrapper div
|
||||
*/
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a PocketBase rich-text HTML string as React elements.
|
||||
*/
|
||||
export function RichText({ html, className }: Props) {
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parse(html);
|
||||
|
||||
if (className) {
|
||||
return <div className={className}>{parsed}</div>;
|
||||
}
|
||||
|
||||
return <>{parsed}</>;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export type { CardBackground } from './Card';
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
|
||||
|
||||
export { Input, Textarea } from './Input';
|
||||
export { RichText } from './RichText';
|
||||
export type { ContainerSize, SectionBackground } from './Section';
|
||||
export { Container, Section } from './Section';
|
||||
|
||||
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { PageContentRecord } from '$shared/api';
|
||||
import { getFirstRecord } from '$shared/api';
|
||||
import { RichText } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Bio section component.
|
||||
* Displays personal biography content from PocketBase.
|
||||
*/
|
||||
export default async function BioSection() {
|
||||
const data = await getFirstRecord<PageContentRecord>('bio', {
|
||||
filter: 'slug = "bio"',
|
||||
});
|
||||
const data = await getFirstRecord<PageContentRecord>('bio');
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<p>{data.content}</p>
|
||||
</div>
|
||||
);
|
||||
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { PageContentRecord } from '$shared/api';
|
||||
import { getFirstRecord } from '$shared/api';
|
||||
import { RichText } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Intro section component.
|
||||
* Displays primary introduction content from PocketBase.
|
||||
*/
|
||||
export default async function IntroSection() {
|
||||
const data = await getFirstRecord<PageContentRecord>('intro', {
|
||||
filter: 'slug = "intro"',
|
||||
});
|
||||
const data = await getFirstRecord<PageContentRecord>('intro');
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||
<p>{data.content}</p>
|
||||
</div>
|
||||
);
|
||||
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user