From 651beb2de65fd4e3b762a0c19ea16cdaed3c727c Mon Sep 17 00:00:00 2001 From: RJ Date: Fri, 7 Nov 2025 16:13:10 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9B=20markdown=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 +++ components/blog/MarkdownRenderer.tsx | 151 +++++++++++++++++++++++++++ content/blog/example.md | 3 +- content/blog/tech/articol-tehnic.md | 41 ++++++++ content/blog/test-complet.md | 109 +++++++++++++++++++ lib/markdown.ts | 149 ++++++++++++++++++++++++++ lib/types/frontmatter.ts | 22 ++++ lib/utils.ts | 53 ++++++++++ package-lock.json | 121 +++++++++++++++++++++ package.json | 5 +- 10 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 components/blog/MarkdownRenderer.tsx create mode 100644 content/blog/tech/articol-tehnic.md create mode 100644 content/blog/test-complet.md create mode 100644 lib/markdown.ts create mode 100644 lib/types/frontmatter.ts create mode 100644 lib/utils.ts diff --git a/.gitignore b/.gitignore index 831ab13..6e806ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,18 @@ node_modules .next dist +out +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.DS_Store +*.pem +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.vercel +*.tsbuildinfo +next-env.d.ts diff --git a/components/blog/MarkdownRenderer.tsx b/components/blog/MarkdownRenderer.tsx new file mode 100644 index 0000000..fa9b0dc --- /dev/null +++ b/components/blog/MarkdownRenderer.tsx @@ -0,0 +1,151 @@ +'use client'; + +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +interface MarkdownRendererProps { + content: string; +} + +export default function MarkdownRenderer({ content }: MarkdownRendererProps) { + return ( + ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + h4: ({ children }) => ( +

{children}

+ ), + h5: ({ children }) => ( +
{children}
+ ), + h6: ({ children }) => ( +
{children}
+ ), + p: ({ children }) => ( +

{children}

+ ), + ul: ({ children }) => ( + + ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) => ( +
  • {children}
  • + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ inline, className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ''); + return !inline && match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); + }, + img: ({ src, alt }) => { + if (!src || typeof src !== 'string') return null; + const isExternal = src.startsWith('http://') || src.startsWith('https://'); + + if (isExternal) { + return ( + {alt + ); + } + + return ( +
    + {alt +
    + ); + }, + a: ({ href, children }) => { + if (!href) return <>{children}; + const isExternal = href.startsWith('http://') || href.startsWith('https://'); + + if (isExternal) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + tbody: ({ children }) => ( + {children} + ), + tr: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + }} + > + {content} +
    + ); +} diff --git a/content/blog/example.md b/content/blog/example.md index 3a7b56a..d901fa7 100644 --- a/content/blog/example.md +++ b/content/blog/example.md @@ -1,8 +1,9 @@ --- title: "Getting Started with Next.js 15" +description: "Learn how to build modern web applications with Next.js 15 and TypeScript." date: "2025-01-07" -excerpt: "Learn how to build modern web applications with Next.js 15 and TypeScript." author: "John Doe" +category: "Tutorial" tags: ["nextjs", "typescript", "tutorial"] --- diff --git a/content/blog/tech/articol-tehnic.md b/content/blog/tech/articol-tehnic.md new file mode 100644 index 0000000..dcad092 --- /dev/null +++ b/content/blog/tech/articol-tehnic.md @@ -0,0 +1,41 @@ +--- +title: "Articol Tehnic din Subdirector" +description: "Test pentru subdirectoare și organizare ierarhică" +date: "2025-01-10" +author: "Tech Writer" +category: "Tehnologie" +tags: ["nextjs", "react", "typescript"] +draft: false +--- + +# Articol Tehnic + +Acesta este un articol stocat într-un subdirector pentru a testa funcționalitatea de organizare ierarhică. + +## Next.js și React + +Next.js este un framework React puternic care oferă: + +- Server-side rendering (SSR) +- Static site generation (SSG) +- API routes +- File-based routing + +## Exemplu de cod TypeScript + +```typescript +interface User { + id: number; + name: string; + email: string; +} + +async function fetchUser(id: number): Promise { + const response = await fetch(`/api/users/${id}`); + return response.json(); +} +``` + +## Concluzie + +Subdirectoarele funcționează perfect pentru organizarea conținutului! diff --git a/content/blog/test-complet.md b/content/blog/test-complet.md new file mode 100644 index 0000000..2caf6c6 --- /dev/null +++ b/content/blog/test-complet.md @@ -0,0 +1,109 @@ +--- +title: "Test Complet Markdown" +description: "Un articol de test care demonstrează toate elementele markdown suportate" +date: "2025-01-15" +author: "Test Author" +category: "Tutorial" +tags: ["markdown", "test", "demo"] +image: "/images/test.jpg" +draft: false +--- + +# Heading 1 + +Acesta este un paragraf normal cu **text bold** și *text italic*. Putem combina ***bold și italic***. + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +## Liste + +### Listă neordonată + +- Item 1 +- Item 2 + - Subitem 2.1 + - Subitem 2.2 +- Item 3 + +### Listă ordonată + +1. Primul item +2. Al doilea item +3. Al treilea item + +## Cod + +Cod inline: `const x = 42;` + +Bloc de cod JavaScript: + +```javascript +function greet(name) { + console.log(`Hello, ${name}!`); + return true; +} + +greet("World"); +``` + +Bloc de cod Python: + +```python +def calculate_sum(a, b): + """Calculate sum of two numbers""" + return a + b + +result = calculate_sum(5, 10) +print(f"Result: {result}") +``` + +## Blockquote + +> Acesta este un blockquote. +> Poate avea multiple linii. +> +> Și paragrafe separate. + +## Link-uri + +[Link intern](/blog/alt-articol) + +[Link extern](https://example.com) + +## Imagini + +![Alt text pentru imagine](/images/sample.jpg) + +## Tabele + +| Coloana 1 | Coloana 2 | Coloana 3 | +|-----------|-----------|-----------| +| Celula 1 | Celula 2 | Celula 3 | +| Date 1 | Date 2 | Date 3 | +| Info 1 | Info 2 | Info 3 | + +## Linie orizontală + +--- + +## Task List (GFM) + +- [x] Task completat +- [ ] Task incomplet +- [ ] Alt task + +## Strikethrough + +~~Text șters~~ + +## Concluzie + +Acesta este sfârșitul articolului de test. diff --git a/lib/markdown.ts b/lib/markdown.ts new file mode 100644 index 0000000..aa5608a --- /dev/null +++ b/lib/markdown.ts @@ -0,0 +1,149 @@ +import fs from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; +import { FrontMatter, Post } from './types/frontmatter'; +import { generateExcerpt } from './utils'; + +const POSTS_PATH = path.join(process.cwd(), 'content', 'blog'); + +export function sanitizePath(inputPath: string): string { + const normalized = path.normalize(inputPath).replace(/^(\.\.[\/\\])+/, ''); + if (normalized.includes('..') || path.isAbsolute(normalized)) { + throw new Error('Invalid path'); + } + return normalized; +} + +export function calculateReadingTime(content: string): number { + const wordsPerMinute = 200; + const words = content.trim().split(/\s+/).length; + return Math.ceil(words / wordsPerMinute); +} + +export function validateFrontmatter(data: any): FrontMatter { + if (!data.title || typeof data.title !== 'string') { + throw new Error('Invalid title'); + } + if (!data.description || typeof data.description !== 'string') { + throw new Error('Invalid description'); + } + if (!data.date || typeof data.date !== 'string') { + throw new Error('Invalid date'); + } + if (!data.author || typeof data.author !== 'string') { + throw new Error('Invalid author'); + } + if (!data.category || typeof data.category !== 'string') { + throw new Error('Invalid category'); + } + if (!Array.isArray(data.tags) || data.tags.length === 0 || data.tags.length > 3) { + throw new Error('Tags must be array with 1-3 items'); + } + + return { + title: data.title, + description: data.description, + date: data.date, + author: data.author, + category: data.category, + tags: data.tags, + image: data.image, + draft: data.draft || false, + }; +} + +export function getPostBySlug(slug: string[]): Post { + const sanitized = slug.map(s => sanitizePath(s)); + const fullPath = path.join(POSTS_PATH, ...sanitized) + '.md'; + + if (!fs.existsSync(fullPath)) { + throw new Error('Post not found'); + } + + const fileContents = fs.readFileSync(fullPath, 'utf8'); + const { data, content } = matter(fileContents); + const frontmatter = validateFrontmatter(data); + + return { + slug: sanitized.join('/'), + frontmatter, + content, + readingTime: calculateReadingTime(content), + excerpt: generateExcerpt(content), + }; +} + +export function getAllPosts(includeContent = false): Post[] { + const posts: Post[] = []; + + function walkDir(dir: string, prefix = ''): void { + const files = fs.readdirSync(dir); + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + walkDir(filePath, prefix ? `${prefix}/${file}` : file); + } else if (file.endsWith('.md')) { + const slug = prefix ? `${prefix}/${file.replace(/\.md$/, '')}` : file.replace(/\.md$/, ''); + try { + const post = getPostBySlug(slug.split('/')); + if (!post.frontmatter.draft) { + posts.push(includeContent ? post : { ...post, content: '' }); + } + } catch (error) { + console.error(`Error loading post ${slug}:`, error); + } + } + } + } + + if (fs.existsSync(POSTS_PATH)) { + walkDir(POSTS_PATH); + } + + return posts.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()); +} + +export function getRelatedPosts(currentSlug: string, category: string, tags: string[], limit = 3): Post[] { + const allPosts = getAllPosts(false); + + const scored = allPosts + .filter(post => post.slug !== currentSlug) + .map(post => { + let score = 0; + if (post.frontmatter.category === category) score += 3; + score += post.frontmatter.tags.filter(tag => tags.includes(tag)).length * 2; + return { post, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score); + + return scored.slice(0, limit).map(({ post }) => post); +} + +export function getAllPostSlugs(): string[][] { + const slugs: string[][] = []; + + function walkDir(dir: string, prefix: string[] = []): void { + const files = fs.readdirSync(dir); + + for (const file of files) { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + walkDir(filePath, [...prefix, file]); + } else if (file.endsWith('.md')) { + slugs.push([...prefix, file.replace(/\.md$/, '')]); + } + } + } + + if (fs.existsSync(POSTS_PATH)) { + walkDir(POSTS_PATH); + } + + return slugs; +} diff --git a/lib/types/frontmatter.ts b/lib/types/frontmatter.ts new file mode 100644 index 0000000..56dfb4b --- /dev/null +++ b/lib/types/frontmatter.ts @@ -0,0 +1,22 @@ +export interface FrontMatter { + title: string; + description: string; + date: string; + author: string; + category: string; + tags: string[]; + image?: string; + draft?: boolean; +} + +export interface Post { + slug: string; + frontmatter: FrontMatter; + content: string; + readingTime: number; + excerpt: string; +} + +export interface BlogParams { + slug: string[]; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..d997fc0 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,53 @@ +export function formatDate(dateString: string): string { + const date = new Date(dateString); + const months = [ + 'ianuarie', 'februarie', 'martie', 'aprilie', 'mai', 'iunie', + 'iulie', 'august', 'septembrie', 'octombrie', 'noiembrie', 'decembrie' + ]; + + return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`; +} + +export function formatRelativeDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'astăzi'; + if (diffDays === 1) return 'ieri'; + if (diffDays < 7) return `acum ${diffDays} zile`; + if (diffDays < 30) return `acum ${Math.floor(diffDays / 7)} săptămâni`; + if (diffDays < 365) return `acum ${Math.floor(diffDays / 30)} luni`; + return `acum ${Math.floor(diffDays / 365)} ani`; +} + +export function generateExcerpt(content: string, maxLength = 160): string { + const text = content + .replace(/^---[\s\S]*?---/, '') + .replace(/!\[.*?\]\(.*?\)/g, '') + .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') + .replace(/[#*`]/g, '') + .trim(); + + if (text.length <= maxLength) return text; + + const truncated = text.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + return truncated.slice(0, lastSpace) + '...'; +} + +export function generateSlug(title: string): string { + const romanianMap: Record = { + 'ă': 'a', 'â': 'a', 'î': 'i', 'ș': 's', 'ț': 't', + 'Ă': 'a', 'Â': 'a', 'Î': 'i', 'Ș': 's', 'Ț': 't' + }; + + return title + .split('') + .map(char => romanianMap[char] || char) + .join('') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} diff --git a/package-lock.json b/package-lock.json index 894ba1e..2979a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tailwindcss/typography": "^0.5.19", "@types/node": "^24.10.0", "@types/react": "^19.2.2", + "@types/react-syntax-highlighter": "^15.5.13", "autoprefixer": "^10.4.21", "gray-matter": "^4.0.3", "next": "^16.0.1", @@ -20,6 +21,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark": "^15.0.1", @@ -40,6 +42,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", @@ -1029,6 +1040,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -1038,6 +1055,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1406,6 +1432,27 @@ "node": ">=0.10.0" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -1599,6 +1646,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -1980,6 +2042,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3041,6 +3117,15 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -3099,6 +3184,42 @@ "react": ">=18" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", + "integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", diff --git a/package.json b/package.json index b52a4c5..864a40d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "validate-posts": "node scripts/validate-posts.js" }, "repository": { "type": "git", @@ -21,6 +22,7 @@ "@tailwindcss/typography": "^0.5.19", "@types/node": "^24.10.0", "@types/react": "^19.2.2", + "@types/react-syntax-highlighter": "^15.5.13", "autoprefixer": "^10.4.21", "gray-matter": "^4.0.3", "next": "^16.0.1", @@ -28,6 +30,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark": "^15.0.1",