سئو: بررسی سئو در صفحه نوشتن + عنوان و توضیح متای هر صفحه
- پنل «بررسی سئو» در صفحه نوشتن مقاله — سبز/نارنجی/قرمز سازگار با فارسی: طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیتر H2)، طول چکیده، تعداد واژه، تیتربندی، متن جایگزین تصاویر، پیوندها و slug. کلمه کلیدی در localStorage نگه داشته میشود (راهنمای ویرایشگر است، نه محتوا) - جدول seo_meta: عنوان و توضیح متای هر صفحه (page_key برای هر ۸ صفحه، seed خودکار، CRUD در /api/admin/seo-meta، خواندن در GET /api/site) - تزریق عنوان/توضیح متا در فرانتاند: صفحههای فهرست از دیتابیس؛ صفحه مقاله و پروژه از محتوای خودشان (بدون تداخل با اثر سطح والد) - دکمه سئو روی هیرو هر صفحه و کنار هیرو صفحه اصلی (فقط حالت ویرایش)
This commit is contained in:
@@ -124,6 +124,15 @@ npm run dev
|
|||||||
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
|
||||||
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
|
||||||
|
|
||||||
|
## سئو
|
||||||
|
|
||||||
|
- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
|
||||||
|
طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
|
||||||
|
تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug)
|
||||||
|
- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
|
||||||
|
عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال میشود
|
||||||
|
- صفحه مقاله و پروژه بهطور خودکار عنوان و توضیح متا را از محتوای خودشان میگیرند
|
||||||
|
|
||||||
## نکات امنیتی
|
## نکات امنیتی
|
||||||
|
|
||||||
- فایل `.env` هرگز commit نمیشود (در `.gitignore` است)
|
- فایل `.env` هرگز commit نمیشود (در `.gitignore` است)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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, "سئوی صفحات"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user