import { useMemo } from 'react' import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react' import type { ArticleBodyDoc } from '../../data/site' export type SeoInput = { title: string slug: string excerpt: string body: ArticleBodyDoc | string[] keyphrase: string } type Status = 'good' | 'ok' | 'bad' type Check = { label: string; status: Status; hint: string } // ---------- استخراج متن/ساختار از سند TipTap ---------- function docToText(body: SeoInput['body']): string { if (Array.isArray(body)) return body.join(' ') const out: string[] = [] const walk = (node: unknown) => { if (!node || typeof node !== 'object') return const n = node as { type?: string; text?: string; content?: unknown[] } if (n.type === 'text' && n.text) out.push(n.text) for (const c of n.content ?? []) walk(c) } walk(body) return out.join(' ') } function docHeadings(body: SeoInput['body']): string[] { if (Array.isArray(body)) return [] const out: string[] = [] const walk = (node: unknown) => { if (!node || typeof node !== 'object') return const n = node as { type?: string; text?: string; attrs?: { level?: number }; content?: unknown[] } if (n.type === 'heading' && n.attrs?.level === 2) { const texts: string[] = [] const collect = (inner: unknown) => { if (!inner || typeof inner !== 'object') return const t = inner as { type?: string; text?: string; content?: unknown[] } if (t.type === 'text' && t.text) texts.push(t.text) for (const c of t.content ?? []) collect(c) } collect(n) out.push(texts.join('')) } for (const c of n.content ?? []) walk(c) } walk(body) return out } function docImages(body: SeoInput['body']): { src: string; alt: string }[] { if (Array.isArray(body)) return [] const out: { src: string; alt: string }[] = [] const walk = (node: unknown) => { if (!node || typeof node !== 'object') return const n = node as { type?: string; attrs?: { src?: string; alt?: string }; content?: unknown[] } if (n.type === 'image') out.push({ src: n.attrs?.src ?? '', alt: n.attrs?.alt ?? '' }) for (const c of n.content ?? []) walk(c) } walk(body) return out } function docLinks(body: SeoInput['body']): string[] { if (Array.isArray(body)) return [] const out: string[] = [] const walk = (node: unknown) => { if (!node || typeof node !== 'object') return const n = node as { type?: string; marks?: { type?: string; attrs?: { href?: string } }[]; content?: unknown[] } for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) out.push(m.attrs.href) for (const c of n.content ?? []) walk(c) } walk(body) return out } function firstParagraph(body: SeoInput['body']): string { if (Array.isArray(body)) return body[0] ?? '' const p = (body as { content?: { type?: string; content?: unknown[] }[] }).content?.find( (c) => c.type === 'paragraph', ) return docToText({ type: 'doc', content: p ? [p] : [] } as never) } // ---------- محاسبه بررسی‌ها ---------- export function useSeoChecks(input: SeoInput): Check[] { return useMemo(() => { const text = docToText(input.body) const words = text.split(/\s+/).filter(Boolean) const kp = input.keyphrase.trim() const has = (s: string, needle: string) => s.includes(needle) const checks: Check[] = [] // عنوان const tLen = input.title.trim().length if (tLen === 0) checks.push({ label: 'عنوان مقاله', status: 'bad', hint: 'برای مقاله عنوان بنویسید.' }) else if (tLen >= 20 && tLen <= 60) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'good', hint: 'طول عنوان مناسب است.' }) else if (tLen >= 10 && tLen <= 70) checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی عنوان بین ۲۰ تا ۶۰ نویسه است.' }) else checks.push({ label: `عنوان (${tLen} نویسه)`, status: 'bad', hint: 'طول عنوان خارج از بازه ۱۰ تا ۷۰ نویسه است.' }) // کلمه کلیدی هدف if (!kp) { checks.push({ label: 'کلمه کلیدی هدف', status: 'ok', hint: 'کلمه کلیدی هدف را بنویسید تا حضور آن در متن بررسی شود.' }) } else { checks.push( has(input.title, kp) ? { label: 'کلمه کلیدی در عنوان', status: 'good', hint: 'کلمه کلیدی در عنوان هست.' } : { label: 'کلمه کلیدی در عنوان', status: 'bad', hint: `کلمه «${kp}» در عنوان نیست.` }, ) const firstP = firstParagraph(input.body) checks.push( has(firstP, kp) ? { label: 'کلمه کلیدی در پاراگراف اول', status: 'good', hint: 'کلمه کلیدی در ابتدای متن هست.' } : { label: 'کلمه کلیدی در پاراگراف اول', status: 'ok', hint: 'بهتر است کلمه کلیدی در پاراگراف اول بیاید.' }, ) const h2s = docHeadings(input.body) if (h2s.length === 0) checks.push({ label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'هنوز تیتر H2 در متن نیست.' }) else checks.push( h2s.some((h) => has(h, kp)) ? { label: 'کلمه کلیدی در تیترها', status: 'good', hint: 'کلمه کلیدی در یکی از تیترها هست.' } : { label: 'کلمه کلیدی در تیترها', status: 'ok', hint: 'کلمه کلیدی در هیچ تیتر H2 نیست.' }, ) } // چکیده (توضیح متا) const eLen = input.excerpt.trim().length if (eLen === 0) checks.push({ label: 'چکیده (توضیح متا)', status: 'bad', hint: 'چکیده بنویسید — روی کارت مقاله و توضیح متای گوگل نمایش داده می‌شود.' }) else if (eLen >= 70 && eLen <= 160) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'good', hint: 'طول چکیده برای توضیح متا ایده‌آل است.' }) else if (eLen >= 40 && eLen <= 200) checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'ok', hint: 'طول پیشنهادی چکیده بین ۷۰ تا ۱۶۰ نویسه است.' }) else checks.push({ label: `چکیده (${eLen} نویسه)`, status: 'bad', hint: 'چکیده خیلی کوتاه یا بلندتر از ۲۰۰ نویسه است.' }) // طول متن if (words.length >= 150) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'good', hint: 'طول متن برای محتوای ارزشمند کافی است.' }) else if (words.length >= 80) checks.push({ label: `طول متن (${words.length} واژه)`, status: 'ok', hint: 'متن کمی کوتاه است — حداقل ۱۵۰ واژه پیشنهاد می‌شود.' }) else checks.push({ label: `طول متن (${words.length} واژه)`, status: 'bad', hint: 'متن خیلی کوتاه است — حداقل ۸۰ و ترجیحاً ۱۵۰ واژه بنویسید.' }) // تیتر H2 const h2s = docHeadings(input.body) if (h2s.length > 0) checks.push({ label: `تیتربندی (${h2s.length} تیتر H2)`, status: 'good', hint: 'متن تیتربندی شده است.' }) else checks.push({ label: 'تیتربندی', status: 'bad', hint: 'برای خوانایی، متن را با تیتر H2 بخش‌بندی کنید.' }) // تصاویر const imgs = docImages(input.body) if (imgs.length === 0) checks.push({ label: 'تصاویر', status: 'ok', hint: 'تصویری داخل متن نیست — افزودن تصویر به جذابیت مقاله کمک می‌کند.' }) else if (imgs.every((i) => i.alt.trim())) checks.push({ label: `تصاویر (${imgs.length} تصویر با متن جایگزین)`, status: 'good', hint: 'همه تصاویر متن جایگزین دارند.' }) else checks.push({ label: 'تصاویر', status: 'bad', hint: 'برای همه تصاویر، متن جایگزین (alt) بنویسید.' }) // لینک‌ها const links = docLinks(input.body) if (links.length > 0) checks.push({ label: `پیوندها (${links.length} لینک)`, status: 'good', hint: 'متن پیوند دارد.' }) else checks.push({ label: 'پیوندها', status: 'ok', hint: 'یک لینک مرتبط (داخلی به پروژه/خدمات یا خارجی) اضافه کنید.' }) // نشانی if (/^article-\d+$/.test(input.slug)) checks.push({ label: 'نشانی (slug)', status: 'bad', hint: 'نشانی پیش‌فرض را به یک نشانی خوانا (انگلیسی) تغییر دهید.' }) else if (input.slug.length > 96) checks.push({ label: 'نشانی (slug)', status: 'ok', hint: 'نشانی خیلی بلند است — کوتاهش کنید.' }) else checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' }) return checks }, [input]) } const STATUS_STYLE = { good: { icon: CheckCircle2Icon, color: 'text-emerald-400' }, ok: { icon: AlertCircleIcon, color: 'text-amber-400' }, bad: { icon: XCircleIcon, color: 'text-red-400' }, } as const /** پنل بررسی سئو — فهرست سبز/نارنجی/قرمز به سبک یوست، سازگار با فارسی */ export function SeoChecklist({ input }: { input: SeoInput }) { const checks = useSeoChecks(input) const good = checks.filter((c) => c.status === 'good').length return (

بررسی سئو

{good} از {checks.length} مورد سبز
) }