sozsoft-platform/ui/src/components/shared/RichText.tsx
2026-08-28 16:44:14 +03:00

118 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useMemo } from 'react'
import type { ReactNode } from 'react'
/**
* Dil anahtarlarında tutulan düz açıklama metnini okunur bir yapıya çevirir.
* Metnin kendisi HTML taşımaz; biçim yalnızca aşağıdaki sözleşmeden türetilir,
* böylece veritabanından gelen içerik sayfaya HTML olarak enjekte edilmez.
*
* - `\n` ile ayrılmış her satır bir blok,
* - `• ` ile başlayan ardışık satırlar madde listesi,
* - `Etiket: ...` biçimindeki satır başlığı kalın,
* - madde içindeki `Ad → açıklama` kalıbında ok öncesi kalın,
* - nokta içeren kod benzeri belirteçler ve adresler tek aralıklı rozet.
*/
const CODE_TOKEN = /((?:https?:\/\/\S+)|(?:[A-Za-z][\w-]*(?:\.[\w*-]+)+))/g
const LABEL_LINE = /^([^:]{2,48}):\s+(.*)$/
const BULLET_HEAD = /^(.+?)\s→\s(.*)$/
const withCode = (text: string, keyPrefix: string): ReactNode[] =>
text.split(CODE_TOKEN).map((part, index) => {
if (index % 2 === 0) return <Fragment key={`${keyPrefix}-t${index}`}>{part}</Fragment>
// Cümle sonundaki noktalama rozetin içinde kalmasın.
const trimmed = part.replace(/[.,;:]+$/, '')
// "e.g." gibi kısaltmalar kod değildir; rozet yalnızca adres ve tanımlayıcılara.
if (!/^https?:/.test(trimmed) && !/[A-Z]/.test(trimmed)) {
return <Fragment key={`${keyPrefix}-p${index}`}>{part}</Fragment>
}
const rest = part.slice(trimmed.length)
return (
<Fragment key={`${keyPrefix}-c${index}`}>
<code className="rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-800 dark:bg-gray-700 dark:text-gray-100">
{trimmed}
</code>
{rest}
</Fragment>
)
})
const Bullet = ({ text, itemKey }: { text: string; itemKey: string }) => {
const head = BULLET_HEAD.exec(text)
if (!head) {
return <li className="ml-4 list-disc">{withCode(text, itemKey)}</li>
}
return (
<li className="ml-4 list-disc">
<span className="font-semibold">{head[1]}</span>
{' → '}
{withCode(head[2], itemKey)}
</li>
)
}
const RichText = ({ text }: { text: string }) => {
const blocks = useMemo(() => {
const lines = text.split('\n').filter((line) => line.trim().length > 0)
const grouped: { type: 'line' | 'list'; items: string[] }[] = []
lines.forEach((line) => {
const trimmed = line.trim()
const isBullet = trimmed.startsWith('•')
const last = grouped[grouped.length - 1]
if (isBullet && last?.type === 'list') {
last.items.push(trimmed.replace(/^•\s*/, ''))
} else if (isBullet) {
grouped.push({ type: 'list', items: [trimmed.replace(/^•\s*/, '')] })
} else {
grouped.push({ type: 'line', items: [trimmed] })
}
})
return grouped
}, [text])
return (
<div className="flex flex-col gap-2 text-sm leading-relaxed">
{blocks.map((block, blockIndex) => {
if (block.type === 'list') {
return (
<ul key={`b${blockIndex}`} className="flex flex-col gap-1">
{block.items.map((item, itemIndex) => (
<Bullet key={`b${blockIndex}-i${itemIndex}`} itemKey={`b${blockIndex}-i${itemIndex}`} text={item} />
))}
</ul>
)
}
const line = block.items[0]
const label = LABEL_LINE.exec(line)
if (blockIndex === 0) {
return (
<p key={`b${blockIndex}`} className="text-base font-semibold">
{withCode(line, `b${blockIndex}`)}
</p>
)
}
if (!label) {
return <p key={`b${blockIndex}`}>{withCode(line, `b${blockIndex}`)}</p>
}
return (
<p key={`b${blockIndex}`}>
<span className="font-semibold">{label[1]}:</span> {withCode(label[2], `b${blockIndex}`)}
</p>
)
})}
</div>
)
}
RichText.displayName = 'TooltipRichText'
export default RichText