story-research-zapwall/lib/markdownRenderer.tsx
2026-01-13 14:49:19 +01:00

240 lines
6.9 KiB
TypeScript

import React from 'react'
import Link from 'next/link'
interface RenderState {
currentList: Array<{ key: string; line: string }>
inCodeBlock: boolean
codeBlockContent: string[]
}
export function renderMarkdown(markdown: string): React.ReactElement[] {
const lines = markdown.split('\n')
const elements: React.ReactElement[] = []
const state: RenderState = {
currentList: [],
inCodeBlock: false,
codeBlockContent: [],
}
lines.forEach((line, index) => {
processLine(line, index, state, elements)
})
closeListIfNeeded('', lines.length, state, elements)
return elements
}
function processLine(line: string, index: number, state: RenderState, elements: React.ReactElement[]): void {
if (line.startsWith('```')) {
handleCodeBlock(line, index, state, elements)
return
}
if (state.inCodeBlock) {
state.codeBlockContent.push(line)
return
}
closeListIfNeeded(line, index, state, elements)
if (renderHeading(line, index, elements)) {
return
}
if (renderListLine(line, index, state)) {
return
}
if (renderLinkLine(line, index, elements)) {
return
}
if (renderBoldAndCodeLine(line, index, elements)) {
return
}
renderParagraphOrBreak(line, index, elements)
}
function renderHeading(line: string, index: number, elements: React.ReactElement[]): boolean {
if (line.startsWith('# ')) {
elements.push(<h1 key={index} className="text-3xl font-bold mt-8 mb-4 text-neon-cyan font-mono">{line.substring(2)}</h1>)
return true
}
if (line.startsWith('## ')) {
elements.push(<h2 key={index} className="text-2xl font-bold mt-6 mb-3 text-neon-cyan font-mono">{line.substring(3)}</h2>)
return true
}
if (line.startsWith('### ')) {
elements.push(<h3 key={index} className="text-xl font-semibold mt-4 mb-2 text-neon-cyan font-mono">{line.substring(4)}</h3>)
return true
}
if (line.startsWith('#### ')) {
elements.push(<h4 key={index} className="text-lg font-semibold mt-3 mb-2 text-neon-cyan font-mono">{line.substring(5)}</h4>)
return true
}
return false
}
function renderListLine(line: string, index: number, state: RenderState): boolean {
if (line.startsWith('- ') || line.startsWith('* ')) {
state.currentList.push({ key: `li-${index}-${line}`, line })
return true
}
return false
}
function renderLinkLine(line: string, index: number, elements: React.ReactElement[]): boolean {
if (line.includes('[') && line.includes('](')) {
renderLink(line, index, elements)
return true
}
return false
}
function renderBoldAndCodeLine(line: string, index: number, elements: React.ReactElement[]): boolean {
if (line.includes('**') || line.includes('`')) {
renderBoldAndCode(line, index, elements)
return true
}
return false
}
function renderParagraphOrBreak(line: string, index: number, elements: React.ReactElement[]): void {
if (line.trim() !== '') {
elements.push(<p key={index} className="mb-4 text-cyber-accent">{line}</p>)
return
}
if (elements.length > 0) {
const last = elements[elements.length - 1] as { type?: unknown }
if (last?.type !== 'br') {
elements.push(<br key={`br-${index}`} />)
}
}
}
function handleCodeBlock(
_line: string,
index: number,
state: RenderState,
elements: React.ReactElement[]
): void {
const nextState = state
if (state.inCodeBlock) {
elements.push(
<pre key={`code-${index}`} className="bg-cyber-darker border border-neon-cyan/20 p-4 rounded-lg overflow-x-auto my-4 text-neon-cyan font-mono text-sm">
<code>{state.codeBlockContent.join('\n')}</code>
</pre>
)
nextState.codeBlockContent = []
nextState.inCodeBlock = false
} else {
nextState.inCodeBlock = true
}
}
function closeListIfNeeded(
line: string,
index: number,
state: RenderState,
elements: React.ReactElement[]
): void {
const nextState = state
if (state.currentList.length > 0 && !line.startsWith('- ') && !line.startsWith('* ') && line.trim() !== '') {
elements.push(
<ul key={`list-${index}`} className="list-disc list-inside mb-4 space-y-1 text-cyber-accent marker:text-neon-cyan">
{state.currentList.map((item) => (
<li key={item.key} className="ml-4">{item.line.substring(2).trim()}</li>
))}
</ul>
)
nextState.currentList = []
}
}
function createLinkElement(
text: string,
href: string,
key: string,
isExternal: boolean
): React.ReactElement {
const className = 'text-neon-green hover:text-neon-cyan underline transition-colors'
if (isExternal) {
return (
<a key={key} href={href} target="_blank" rel="noopener noreferrer" className={className}>
{text}
</a>
)
}
return (
<Link key={key} href={href} className={className}>
{text}
</Link>
)
}
function renderLink(line: string, index: number, elements: React.ReactElement[]): void {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
let lastIndex = 0
const parts: (string | React.ReactElement)[] = []
let match
while ((match = linkRegex.exec(line)) !== null) {
if (match[2]) {
if (!match[1] || match.index > lastIndex) {
parts.push(line.substring(lastIndex, match.index))
}
const href = match[2]
const isExternal = href.startsWith('http')
if (match[1]) {
parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
}
lastIndex = match.index + match[0].length
}
}
if (lastIndex < line.length) {
parts.push(line.substring(lastIndex))
}
elements.push(<p key={index} className="mb-4">{parts}</p>)
}
function renderBoldAndCode(line: string, index: number, elements: React.ReactElement[]): void {
const parts: (string | React.ReactElement)[] = []
const codeRegex = /`([^`]+)`/g
let codeMatch
let lastIndex = 0
while ((codeMatch = codeRegex.exec(line)) !== null) {
if (codeMatch.index > lastIndex) {
const beforeCode = line.substring(lastIndex, codeMatch.index)
processBold(beforeCode, parts)
}
parts.push(
<code key={`code-${codeMatch.index}`} className="bg-cyber-darker text-neon-cyan px-1 py-0.5 rounded text-sm font-mono border border-neon-cyan/20">
{codeMatch[1]}
</code>
)
lastIndex = codeMatch.index + codeMatch[0].length
}
if (lastIndex < line.length) {
const remaining = line.substring(lastIndex)
processBold(remaining, parts)
}
elements.push(<p key={index} className="mb-4">{parts.length > 0 ? parts : line}</p>)
}
function processBold(text: string, parts: (string | React.ReactElement)[]): void {
const boldParts = text.split(/(\*\*[^*]+\*\*)/g)
const seen = new Map<string, number>()
boldParts.forEach((part) => {
if (part.startsWith('**') && part.endsWith('**')) {
const count = (seen.get(part) ?? 0) + 1
seen.set(part, count)
parts.push(<strong key={`bold-${count}-${part}`} className="text-neon-green font-semibold">{part.slice(2, -2)}</strong>)
} else if (part) {
parts.push(part)
}
})
}