سئو: بررسی سئو در صفحه نوشتن + عنوان و توضیح متای هر صفحه
- پنل «بررسی سئو» در صفحه نوشتن مقاله — سبز/نارنجی/قرمز سازگار با فارسی: طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیتر H2)، طول چکیده، تعداد واژه، تیتربندی، متن جایگزین تصاویر، پیوندها و slug. کلمه کلیدی در localStorage نگه داشته میشود (راهنمای ویرایشگر است، نه محتوا) - جدول seo_meta: عنوان و توضیح متای هر صفحه (page_key برای هر ۸ صفحه، seed خودکار، CRUD در /api/admin/seo-meta، خواندن در GET /api/site) - تزریق عنوان/توضیح متا در فرانتاند: صفحههای فهرست از دیتابیس؛ صفحه مقاله و پروژه از محتوای خودشان (بدون تداخل با اثر سطح والد) - دکمه سئو روی هیرو هر صفحه و کنار هیرو صفحه اصلی (فقط حالت ویرایش)
This commit is contained in:
@@ -4,6 +4,7 @@ import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { ImageField } from './admin/ImageField'
|
||||
|
||||
@@ -71,8 +72,13 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
</motion.p>
|
||||
<div className="mt-8 h-px w-28 bg-copper" />
|
||||
|
||||
{/* ویرایش تصویر پسزمینه — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 [&>div]:!mt-0">
|
||||
{/* ویرایش تصویر پسزمینه و سئوی صفحه — فقط در حالت ویرایش ادمین */}
|
||||
<div className="mt-6 flex items-start gap-3 [&>div]:!mt-0">
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی این صفحه"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === pageKey)}
|
||||
/>
|
||||
<ImageField label="تصویر پسزمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -127,11 +127,14 @@ export function Hero() {
|
||||
<ChevronDownIcon className="h-4 w-4 animate-bounce" />
|
||||
</a>
|
||||
|
||||
<SingletonPill
|
||||
resource="hero"
|
||||
label="تنظیمات هیرو (واژه مسی، دکمهها)"
|
||||
className="absolute left-6 top-24 z-20 lg:left-10"
|
||||
/>
|
||||
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
|
||||
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمهها)" />
|
||||
<SingletonPill
|
||||
resource="seo-meta"
|
||||
label="عنوان و توضیح سئوی صفحه اصلی"
|
||||
select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user