سئو: بررسی سئو در صفحه نوشتن + عنوان و توضیح متای هر صفحه

- پنل «بررسی سئو» در صفحه نوشتن مقاله — سبز/نارنجی/قرمز سازگار با فارسی:
  طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیتر H2)، طول چکیده،
  تعداد واژه، تیتربندی، متن جایگزین تصاویر، پیوندها و slug.
  کلمه کلیدی در localStorage نگه داشته می‌شود (راهنمای ویرایشگر است، نه محتوا)
- جدول seo_meta: عنوان و توضیح متای هر صفحه (page_key برای هر ۸ صفحه،
  seed خودکار، CRUD در /api/admin/seo-meta، خواندن در GET /api/site)
- تزریق عنوان/توضیح متا در فرانت‌اند: صفحه‌های فهرست از دیتابیس؛ صفحه
  مقاله و پروژه از محتوای خودشان (بدون تداخل با اثر سطح والد)
- دکمه سئو روی هیرو هر صفحه و کنار هیرو صفحه اصلی (فقط حالت ویرایش)
This commit is contained in:
MMD
2026-09-07 03:12:12 +03:30
parent 17345daa81
commit a29deb8bb1
19 changed files with 380 additions and 15 deletions
+8 -2
View File
@@ -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>
)
}
+8 -5
View File
@@ -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>
)
}