Added animated auto-opening link modal, custom image node with isolated caption deletion, fixed single-select category data binding, improved featured image layout, and updated README.
138 lines
7.9 KiB
TypeScript
138 lines
7.9 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { AlertCircleIcon, CheckCircle2Icon, XCircleIcon } from 'lucide-react'
|
|
import type { ArticleBodyDoc } from '../../data/site'
|
|
import { docHeadings, docImages, docLinks, docToText, firstParagraph } from '../../utils/tiptapDoc'
|
|
|
|
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 }
|
|
|
|
// ---------- محاسبه بررسیها ----------
|
|
|
|
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, 2)
|
|
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, 2)
|
|
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 (
|
|
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="بررسی سئو">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-bold text-copper">بررسی سئو</h2>
|
|
<span className="text-xs font-light text-ivory/50">
|
|
{good} از {checks.length} مورد سبز
|
|
</span>
|
|
</div>
|
|
<ul className="mt-4 grid gap-2.5">
|
|
{checks.map((c) => {
|
|
const S = STATUS_STYLE[c.status]
|
|
return (
|
|
<li key={c.label} className="flex items-start gap-3">
|
|
<S.icon className={`mt-1 h-4 w-4 shrink-0 ${S.color}`} aria-hidden="true" />
|
|
<div>
|
|
<p className="text-sm font-light text-ivory/85">{c.label}</p>
|
|
<p className="text-xs font-light leading-6 text-ivory/45">{c.hint}</p>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|