diff --git a/package.json b/package.json
index 8ec288a..6fce2de 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
},
"dependencies": {
"clsx": "^2.1.1",
+ "html-react-parser": "^6.1.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
diff --git a/src/shared/ui/RichText/index.ts b/src/shared/ui/RichText/index.ts
new file mode 100644
index 0000000..66c5463
--- /dev/null
+++ b/src/shared/ui/RichText/index.ts
@@ -0,0 +1 @@
+export { RichText } from './ui/RichText';
diff --git a/src/shared/ui/RichText/ui/RichText.test.tsx b/src/shared/ui/RichText/ui/RichText.test.tsx
new file mode 100644
index 0000000..de92582
--- /dev/null
+++ b/src/shared/ui/RichText/ui/RichText.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react';
+import { RichText } from './RichText';
+
+describe('RichText', () => {
+ describe('rendering', () => {
+ it('renders a paragraph from
tag', () => {
+ render();
+ expect(screen.getByText('Hello world').tagName).toBe('P');
+ });
+
+ it('renders bold text from tag', () => {
+ render();
+ expect(screen.getByText('Bold').tagName).toBe('STRONG');
+ });
+
+ it('renders a link from tag', () => {
+ render();
+ const link = screen.getByRole('link', { name: 'Link' });
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ });
+
+ it('renders nested tags', () => {
+ render();
+ expect(screen.getByText('emphasis').tagName).toBe('EM');
+ });
+
+ it('renders nothing for empty string', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders multiple sibling elements', () => {
+ render();
+ expect(screen.getByText('First')).toBeInTheDocument();
+ expect(screen.getByText('Second')).toBeInTheDocument();
+ });
+ });
+
+ describe('className passthrough', () => {
+ it('applies className to the wrapper', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('prose');
+ });
+ });
+});
diff --git a/src/shared/ui/RichText/ui/RichText.tsx b/src/shared/ui/RichText/ui/RichText.tsx
new file mode 100644
index 0000000..a1e5ced
--- /dev/null
+++ b/src/shared/ui/RichText/ui/RichText.tsx
@@ -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 {parsed}
;
+ }
+
+ return <>{parsed}>;
+}
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 586af4f..45bda75 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -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';
diff --git a/src/widgets/BioSection/ui/BioSection/BioSection.tsx b/src/widgets/BioSection/ui/BioSection/BioSection.tsx
index 9c3c3ab..518c3bf 100644
--- a/src/widgets/BioSection/ui/BioSection/BioSection.tsx
+++ b/src/widgets/BioSection/ui/BioSection/BioSection.tsx
@@ -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('bio', {
- filter: 'slug = "bio"',
- });
+ const data = await getFirstRecord('bio');
if (!data) {
notFound();
}
- return (
-
- );
+ return ;
}
diff --git a/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx b/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx
index 28a242e..28b54f4 100644
--- a/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx
+++ b/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx
@@ -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('intro', {
- filter: 'slug = "intro"',
- });
+ const data = await getFirstRecord('intro');
if (!data) {
notFound();
}
- return (
-
- );
+ return ;
}
diff --git a/yarn.lock b/yarn.lock
index eb0a8ab..bb38caf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3239,6 +3239,44 @@ __metadata:
languageName: node
linkType: hard
+"dom-serializer@npm:^3.0.0":
+ version: 3.1.1
+ resolution: "dom-serializer@npm:3.1.1"
+ dependencies:
+ domelementtype: "npm:^3.0.0"
+ domhandler: "npm:^6.0.0"
+ entities: "npm:^8.0.0"
+ checksum: 10c0/dc700204f0ef4a4c5a344bd8773703d5476dcca1a4af8b2d3fd9bcbbace833439b6ea3d3c48c4b387fa0b2456dd839caca354eed7f7c7f17bc47da8e217742ca
+ languageName: node
+ linkType: hard
+
+"domelementtype@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "domelementtype@npm:3.0.0"
+ checksum: 10c0/26e8ef992769c4f9bce941eb0cff7ce2ba3f1b3bf77710bb4b029055030625892e83da326cc36b1e444cf3bfdea7d1954791ee2227746387465da9929d16d954
+ languageName: node
+ linkType: hard
+
+"domhandler@npm:6.0.1, domhandler@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "domhandler@npm:6.0.1"
+ dependencies:
+ domelementtype: "npm:^3.0.0"
+ checksum: 10c0/8655204dd9612b55813d5880e3e87e134d6dfb2de4bd80f342b3c97f41b167576a8c66c0449c2423999953aedfcda290f7be253a6f9bf71e815afa85f939d44e
+ languageName: node
+ linkType: hard
+
+"domutils@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "domutils@npm:4.0.2"
+ dependencies:
+ dom-serializer: "npm:^3.0.0"
+ domelementtype: "npm:^3.0.0"
+ domhandler: "npm:^6.0.0"
+ checksum: 10c0/59827827ecf15ed1f43f4cb8db374484b6089bf40e32cb41c8e381525aeb5ef5d029e4f9d5f74a418bf3217b87a6cbabdf5b4ebed0a018bc533bd6349c46a739
+ languageName: node
+ linkType: hard
+
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -3288,6 +3326,13 @@ __metadata:
languageName: node
linkType: hard
+"entities@npm:^8.0.0":
+ version: 8.0.0
+ resolution: "entities@npm:8.0.0"
+ checksum: 10c0/938e631664c19451823344a351aeeafd74fae2d5fa51e4d5b6ff635afaefd4bacf0f609989888c04c42733f46ffdac15211608267ebb02488005891a4793e94d
+ languageName: node
+ linkType: hard
+
"env-paths@npm:^2.2.0":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
@@ -4291,6 +4336,16 @@ __metadata:
languageName: node
linkType: hard
+"html-dom-parser@npm:7.1.0":
+ version: 7.1.0
+ resolution: "html-dom-parser@npm:7.1.0"
+ dependencies:
+ domhandler: "npm:6.0.1"
+ htmlparser2: "npm:12.0.0"
+ checksum: 10c0/e73b0c2e8bbe809ff877bf2483f6547f4797ee55c1c6d0f486d54ce7310e799c36986328f11dde1ce99608939a06efdf1d02c45a0abd0ec40b405b230c3dffdf
+ languageName: node
+ linkType: hard
+
"html-encoding-sniffer@npm:^6.0.0":
version: 6.0.0
resolution: "html-encoding-sniffer@npm:6.0.0"
@@ -4307,6 +4362,36 @@ __metadata:
languageName: node
linkType: hard
+"html-react-parser@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "html-react-parser@npm:6.1.0"
+ dependencies:
+ domhandler: "npm:6.0.1"
+ html-dom-parser: "npm:7.1.0"
+ react-property: "npm:2.0.2"
+ style-to-js: "npm:1.1.21"
+ peerDependencies:
+ "@types/react": 0.14 || 15 || 16 || 17 || 18 || 19
+ react: 0.14 || 15 || 16 || 17 || 18 || 19
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/1626454d3e3edf01b8e626b6f4a150b9ab013b4d379e5038506c93c3ce7cfb09a78abff079512ecbd4dd6d840c0bbcd55f722ee3302a70400c760e9109891b49
+ languageName: node
+ linkType: hard
+
+"htmlparser2@npm:12.0.0":
+ version: 12.0.0
+ resolution: "htmlparser2@npm:12.0.0"
+ dependencies:
+ domelementtype: "npm:^3.0.0"
+ domhandler: "npm:^6.0.0"
+ domutils: "npm:^4.0.2"
+ entities: "npm:^8.0.0"
+ checksum: 10c0/3fcdce24c06fc4c9c42c8142d6c139104a2c30f901ce046cb0bdeaa8678445294aaf4506569464a5c853c8b1d89609f7306ea133efd966bf703f574a394dcff9
+ languageName: node
+ linkType: hard
+
"http-cache-semantics@npm:^4.1.1":
version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0"
@@ -4390,6 +4475,13 @@ __metadata:
languageName: node
linkType: hard
+"inline-style-parser@npm:0.2.7":
+ version: 0.2.7
+ resolution: "inline-style-parser@npm:0.2.7"
+ checksum: 10c0/d884d76f84959517430ae6c22f0bda59bb3f58f539f99aac75a8d786199ec594ed648c6ab4640531f9fc244b0ed5cd8c458078e592d016ef06de793beb1debff
+ languageName: node
+ linkType: hard
+
"internal-slot@npm:^1.1.0":
version: 1.1.0
resolution: "internal-slot@npm:1.1.0"
@@ -5859,6 +5951,7 @@ __metadata:
eslint: "npm:^9"
eslint-config-next: "npm:16.2.4"
eslint-plugin-storybook: "npm:^10.3.5"
+ html-react-parser: "npm:^6.1.0"
jsdom: "npm:^29.0.2"
lefthook: "npm:^2.1.6"
next: "npm:16.2.4"
@@ -6016,6 +6109,13 @@ __metadata:
languageName: node
linkType: hard
+"react-property@npm:2.0.2":
+ version: 2.0.2
+ resolution: "react-property@npm:2.0.2"
+ checksum: 10c0/27a3dfa68d29d45fc3582552715203291d26c6f1b228fdb6775e7ca19b10753141dbe98a0aa3a4da745b39fcd7427dc2d623055e63742062231ee18692a6f0fa
+ languageName: node
+ linkType: hard
+
"react@npm:19.2.4":
version: 19.2.4
resolution: "react@npm:19.2.4"
@@ -6752,6 +6852,24 @@ __metadata:
languageName: node
linkType: hard
+"style-to-js@npm:1.1.21":
+ version: 1.1.21
+ resolution: "style-to-js@npm:1.1.21"
+ dependencies:
+ style-to-object: "npm:1.0.14"
+ checksum: 10c0/94231aa80f58f442c3a5ae01a21d10701e5d62f96b4b3e52eab3499077ee52df203cc0df4a1a870707f5e99470859136ea8657b782a5f4ca7934e0ffe662a588
+ languageName: node
+ linkType: hard
+
+"style-to-object@npm:1.0.14":
+ version: 1.0.14
+ resolution: "style-to-object@npm:1.0.14"
+ dependencies:
+ inline-style-parser: "npm:0.2.7"
+ checksum: 10c0/854d9e9b77afc336e6d7b09348e7939f2617b34eb0895824b066d8cd1790284cb6d8b2ba36be88025b2595d715dba14b299ae76e4628a366541106f639e13679
+ languageName: node
+ linkType: hard
+
"styled-jsx@npm:5.1.6":
version: 5.1.6
resolution: "styled-jsx@npm:5.1.6"