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