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 ( -
-

{data.content}

-
- ); + 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 ( -
-

{data.content}

-
- ); + 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"