119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
|
|
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
|