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

- پنل «بررسی سئو» در صفحه نوشتن مقاله — سبز/نارنجی/قرمز سازگار با فارسی:
  طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیتر 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
+9
View File
@@ -124,6 +124,15 @@ npm run dev
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف) (پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند.
## سئو
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug)
- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال می‌شود
- صفحه مقاله و پروژه به‌طور خودکار عنوان و توضیح متا را از محتوای خودشان می‌گیرند
## نکات امنیتی ## نکات امنیتی
- فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است) - فایل `.env` هرگز commit نمی‌شود (در `.gitignore` است)
+2
View File
@@ -138,6 +138,8 @@ Authorization: Bearer <access_token>
در startup/seed خودکار به سند تبدیل می‌شوند در startup/seed خودکار به سند تبدیل می‌شوند
- seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند - seed هم بدنه‌های لیستی را قبل از درج تبدیل می‌کند
| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود. منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیس‌های موجود اضافه می‌شود.
### تصاویر و MinIO ### تصاویر و MinIO
+4
View File
@@ -63,6 +63,9 @@ from ..schemas import (
ProjectOut, ProjectOut,
ProjectPatch, ProjectPatch,
ReorderIn, ReorderIn,
SeoMetaIn,
SeoMetaOut,
SeoMetaPatch,
SectionHeadingIn, SectionHeadingIn,
SectionHeadingOut, SectionHeadingOut,
SectionHeadingPatch, SectionHeadingPatch,
@@ -228,6 +231,7 @@ register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn
register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch) register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch) register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch) register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد # آپلود تصویر به Object Storage (MinIO) — URL نسبی برمی‌گرداند تا بک‌اند ذخیره‌سازی قابل تعویض باشد
+1
View File
@@ -132,4 +132,5 @@ def get_site(db: Session = Depends(get_db)):
"contact_section": db.query(models.ContactSectionContent).first(), "contact_section": db.query(models.ContactSectionContent).first(),
"newsletter": db.query(models.NewsletterSectionContent).first(), "newsletter": db.query(models.NewsletterSectionContent).first(),
"footer": db.query(models.FooterContent).first(), "footer": db.query(models.FooterContent).first(),
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
} }
+11
View File
@@ -340,6 +340,17 @@ class FooterContent(Base):
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
class SeoMeta(Base):
"""عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ..."""
__tablename__ = "seo_meta"
id: Mapped[int] = mapped_column(primary_key=True)
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256), default="")
description: Mapped[str] = mapped_column(Text, default="")
class ContactMessage(Base): class ContactMessage(Base):
__tablename__ = "contact_messages" __tablename__ = "contact_messages"
+19
View File
@@ -243,6 +243,13 @@ class FooterOut(ORMModel):
socials: list[dict] socials: list[dict]
class SeoMetaOut(ORMModel):
id: int
page_key: str
title: str
description: str
class ContactMessageOut(ORMModel): class ContactMessageOut(ORMModel):
id: int id: int
name: str name: str
@@ -504,6 +511,18 @@ class FooterIn(BaseModel):
socials: list[dict] = [] socials: list[dict] = []
class SeoMetaIn(BaseModel):
page_key: str = Field(min_length=1, max_length=32)
title: str = ""
description: str = ""
class SeoMetaPatch(BaseModel):
page_key: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
class MessageReadIn(BaseModel): class MessageReadIn(BaseModel):
is_read: bool = True is_read: bool = True
+1
View File
@@ -43,6 +43,7 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
(models.TimelineItem, seed_data.TIMELINE, "تایم‌لاین"), (models.TimelineItem, seed_data.TIMELINE, "تایم‌لاین"),
(models.WhyUsItem, seed_data.WHY, "چرا آرامش"), (models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"), (models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
] ]
+5
View File
@@ -451,3 +451,8 @@ FOOTER = {
{"label": "تلگرام", "href": "#", "icon": "telegram"}, {"label": "تلگرام", "href": "#", "icon": "telegram"},
], ],
} }
SEO_META = [
{"page_key": key, "title": "", "description": ""}
for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
]
+14 -4
View File
@@ -16,6 +16,7 @@ import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage'; import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage'; import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData } from './api'; import { SiteProvider, useSiteData } from './api';
import { seoKeyForPath, setMetaDescription } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
@@ -64,8 +65,21 @@ function AnimatedRoutes() {
function AppChrome() { function AppChrome() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isAuthenticated } = useAdmin(); const { isAuthenticated } = useAdmin();
const { data } = useSiteData();
const isAdminPage = pathname.startsWith('/admin'); const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => {
if (isAdminPage || !data) return;
const key = seoKeyForPath(pathname);
if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key);
const brand = data.brand.name;
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
document.title = meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand);
setMetaDescription(meta?.description ?? '');
}, [pathname, data, isAdminPage]);
return ( return (
<> <>
{!isAdminPage && <Header />} {!isAdminPage && <Header />}
@@ -80,10 +94,6 @@ function AppChrome() {
function AppShell() { function AppShell() {
const { data, error, reload } = useSiteData(); const { data, error, reload } = useSiteData();
useEffect(() => {
if (data?.brand.name) document.title = data.brand.name;
}, [data?.brand.name]);
return ( return (
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory"> <div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory">
<Preloader /> <Preloader />
+27
View File
@@ -6,6 +6,7 @@ import { useSiteData } from '../api'
import { useAdmin } from './AdminContext' import { useAdmin } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { ArticleEditor } from '../components/admin/ArticleEditor' import { ArticleEditor } from '../components/admin/ArticleEditor'
import { SeoChecklist } from '../components/admin/SeoChecklist'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import type { ArticleBodyDoc } from '../data/site' import type { ArticleBodyDoc } from '../data/site'
@@ -61,6 +62,12 @@ export function ArticleWritePage() {
) )
const [createdId, setCreatedId] = useState<number | null>(existing?.id ?? null) const [createdId, setCreatedId] = useState<number | null>(existing?.id ?? null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
// کلمه کلیدی هدف فقط راهنمای سئو است — در localStorage می‌ماند، نه در دیتابیس
const [keyphrase, setKeyphrase] = useState(() => localStorage.getItem(`seo-keyphrase-${id ?? 'new'}`) ?? '')
useEffect(() => {
localStorage.setItem(`seo-keyphrase-${id ?? 'new'}`, keyphrase)
}, [keyphrase, id])
const draftRef = useRef(draft) const draftRef = useRef(draft)
draftRef.current = draft draftRef.current = draft
@@ -247,9 +254,29 @@ export function ArticleWritePage() {
</div> </div>
{/* چکیده و متادیتا */} {/* چکیده و متادیتا */}
<SeoChecklist
input={{
title: draft.title,
slug: draft.slug,
excerpt: draft.excerpt,
body: draft.body,
keyphrase,
}}
/>
<section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله"> <section className="mt-12 border-t border-ivory/10 pt-8" aria-label="جزئیات مقاله">
<h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2> <h2 className="text-sm font-bold text-copper">جزئیات انتشار</h2>
<div className="mt-5 grid gap-5"> <div className="mt-5 grid gap-5">
<div>
<label htmlFor="seo-keyphrase" className="text-xs font-light text-ivory/50">کلمه کلیدی هدف (فقط برای راهنمای سئو — در سایت نمایش داده نمی‌شود)</label>
<input
id="seo-keyphrase"
type="text"
value={keyphrase}
onChange={(e) => setKeyphrase(e.target.value)}
className="mt-2 w-full border border-ivory/15 bg-char px-3 py-2 text-sm font-light text-ivory focus:border-copper focus:outline-none"
/>
</div>
<div> <div>
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label> <label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea <textarea
+6 -1
View File
@@ -228,10 +228,13 @@ export function SingletonPill({
resource, resource,
label, label,
className = '', className = '',
select,
}: { }: {
resource: string resource: string
label: string label: string
className?: string className?: string
/** انتخاب رکورد از درخت داده — برای مجموعه‌هایی که یک رکورد خاص اینجاست (مثل سئو) */
select?: (data: NonNullable<ReturnType<typeof useSiteData>['data']>) => ItemWithId | undefined
}) { }) {
const { isAuthenticated, isEditMode } = useAdmin() const { isAuthenticated, isEditMode } = useAdmin()
const config = ENTITIES[resource] const config = ENTITIES[resource]
@@ -239,7 +242,9 @@ export function SingletonPill({
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
if (!config || !isAuthenticated || !isEditMode) return null if (!config || !isAuthenticated || !isEditMode) return null
const item = data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined const item = select
? select(data!)
: (data?.[config.dataKey as keyof NonNullable<typeof data>] as ItemWithId | undefined)
if (!item) return null if (!item) return null
return ( return (
+11
View File
@@ -133,6 +133,17 @@ export const ENTITIES: Record<string, EntityConfig> = {
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key',
fields: [
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
],
defaults: { page_key: '', title: '', description: '' },
},
// ---------- رکوردهای یگانه ---------- // ---------- رکوردهای یگانه ----------
brand: { brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton', resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
+1
View File
@@ -153,6 +153,7 @@ export interface SiteData {
contact_section: ContactSectionContent contact_section: ContactSectionContent
newsletter: NewsletterContent newsletter: NewsletterContent
footer: FooterContent footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; description: string }[]
} }
interface SiteContextValue { interface SiteContextValue {
+8 -2
View File
@@ -4,6 +4,7 @@ import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react' import { ChevronLeftIcon } from 'lucide-react'
import { usePageHero } from '../api' import { usePageHero } from '../api'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
import { SingletonPill } from '../admin/Editable'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
import { ImageField } from './admin/ImageField' import { ImageField } from './admin/ImageField'
@@ -71,8 +72,13 @@ export function PageHero({ pageKey }: PageHeroProps) {
</motion.p> </motion.p>
<div className="mt-8 h-px w-28 bg-copper" /> <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)} /> <ImageField label="تصویر پس‌زمینه" value={hero.image} onSave={(url) => saveField('image', url)} />
</div> </div>
</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>
)
}
+6 -3
View File
@@ -127,11 +127,14 @@ export function Hero() {
<ChevronDownIcon className="h-4 w-4 animate-bounce" /> <ChevronDownIcon className="h-4 w-4 animate-bounce" />
</a> </a>
<div className="absolute left-6 top-24 z-20 flex flex-col gap-2 lg:left-10">
<SingletonPill resource="hero" label="تنظیمات هیرو (واژه مسی، دکمه‌ها)" />
<SingletonPill <SingletonPill
resource="hero" resource="seo-meta"
label="تنظیمات هیرو (واژه مسی، دکمه‌ها)" label="عنوان و توضیح سئوی صفحه اصلی"
className="absolute left-6 top-24 z-20 lg:left-10" select={(d) => d.seo_meta.find((s) => s.page_key === 'home')}
/> />
</div>
</section> </section>
) )
} }
+10 -1
View File
@@ -7,6 +7,8 @@ import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard'; import { ArticleCard } from '../components/ArticleCard';
import { Newsletter } from '../components/home/Newsletter'; import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
import { setMetaDescription } from '../seo';
import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import type { Article } from '../data/site'; import type { Article } from '../data/site';
@@ -29,9 +31,16 @@ function renderBody(article: Article): { html: string | null; legacy: string[] }
export function ArticleDetail() { export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>(); const { slug } = useParams<{slug: string;}>();
const { articles } = useSite(); const { articles, brand } = useSite();
const article = articles.find((a) => a.slug === slug); const article = articles.find((a) => a.slug === slug);
// عنوان تب و توضیح متا از خود مقاله
useEffect(() => {
if (!article) return;
document.title = `${article.title} | ${brand}`;
setMetaDescription(article.excerpt);
}, [article?.id]);
if (!article) { if (!article) {
return ( return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
+11 -2
View File
@@ -6,15 +6,24 @@ import { ButtonLink } from '../components/Button'
import { ProjectCard } from '../components/ProjectCard' import { ProjectCard } from '../components/ProjectCard'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { useSite, useStatusLabel } from '../api' import { useEffect } from 'react';
import { useSite, useStatusLabel } from '../api';
import { setMetaDescription } from '../seo';
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const { projects } = useSite() const { projects, brand } = useSite()
const project = projects.find((p) => p.slug === slug) const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '') const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
// عنوان تب و توضیح متا از خود پروژه
useEffect(() => {
if (!project) return;
document.title = `${project.title} | ${brand}`;
setMetaDescription(project.summary);
}, [project?.id]);
if (!project) { if (!project) {
return ( return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
+24
View File
@@ -0,0 +1,24 @@
// تنظیم عنوان صفحه و توضیح متا — محتوای سئو از دیتابیس می‌آید
export function setMetaDescription(content: string): void {
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
if (!content.trim()) {
el?.remove()
return
}
if (!el) {
el = document.createElement('meta')
el.setAttribute('name', 'description')
document.head.appendChild(el)
}
el.setAttribute('content', content.trim())
}
/** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */
export function seoKeyForPath(pathname: string): string | null {
if (pathname === '/') return 'home'
const seg = pathname.split('/')[1] ?? ''
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
// مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند
return known.includes(seg) && pathname === `/${seg}` ? seg : null
}