sozsoft-platform/ui/src/components/shared/RichText.tsx

119 lines
3.9 KiB
TypeScript
Raw Normal View History

import { Fragment, useMemo } from 'react'
import type { ReactNode } from 'react'
/**
* Dil anahtarlarında tutulan düz ı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