From aa13d41b6d58a0748ee04da3fb887c937b077361 Mon Sep 17 00:00:00 2001 From: aramesh-site deploy Date: Sun, 25 Oct 2026 19:51:50 +0000 Subject: [PATCH] =?UTF-8?q?fix(blog):=20=DA=A9=D9=84=DB=8C=D8=AF=20=D8=B2?= =?UTF-8?q?=D8=A8=D8=A7=D9=86=20=D8=AF=D8=B1=20=D8=B5=D9=81=D8=AD=D9=87?= =?UTF-8?q?=D9=94=20=D9=85=D9=82=D8=A7=D9=84=D9=87=20=DB=B4=DB=B0=DB=B4=20?= =?UTF-8?q?=D9=85=DB=8C=E2=80=8C=D8=AF=D8=A7=D8=AF=20+=20=D8=A7=D8=B3?= =?UTF-8?q?=D9=84=D8=A7=DA=AF=E2=80=8C=D9=87=D8=A7=DB=8C=20=D8=A7=D9=86?= =?UTF-8?q?=DA=AF=D9=84=DB=8C=D8=B3=DB=8C=20=D9=86=D8=A7=D9=85=D8=B9=D8=AA?= =?UTF-8?q?=D8=A8=D8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit باگ کاربر: در پیش‌نمایش فارسی مقاله، دکمهٔ EN به '/en' + اسلاگ فارسی می‌رفت («مقاله‌ای وجود ندارد») چون اسلاگ دو زبان فرق دارد. - LangToggle: نگاشت آگاه از اسلاگ مقاله (fa↔en از روی slug_en) — هم статье و هم فهرست وبلاگ - seo.ts: مسیر alternate صریح (altFa/altEn)؛ پیش از این localeHref پیشوند ساده می‌ساخت و hreflang درست سرور را بعد از هیدریشن خراب می‌کرد - backend: /en/blog/{fa-slug} → 301 به اسلاگ انگلیسی و /blog/{en-slug} → 301 به نسخهٔ انگلیسی (جلوگیری از ۴۰۴ برای نشانی‌های اشتباه) - داده: ۱۰ مقدار slug_en که «عنوان با فاصله» بودند به اسلاگ واقعی تبدیل شدند (مثل 'Principles of Commercial Building Design' → principles-of-commercial-...)؛ URL با فاصله هرگز کار نمی‌کرد --- backend/app/seo_pages.py | 157 ++++++++++++++++++++----- frontend/src/components/LangToggle.tsx | 20 +++- frontend/src/pages/ArticleDetail.tsx | 25 +++- frontend/src/seo.ts | 16 ++- 4 files changed, 182 insertions(+), 36 deletions(-) diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 463c9e9..995751e 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -32,7 +32,7 @@ from urllib.parse import quote from fastapi import APIRouter, Depends, Response from fastapi.encoders import jsonable_encoder -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session from . import models @@ -50,6 +50,11 @@ FALLBACK_EN_BRAND = "Aramesh Construction Group" # تصویر پیش‌فرض شبکه‌های اجتماعی — در frontend/public بیلد می‌شود DEFAULT_OG_IMAGE = "/og-default.jpg" DEFAULT_OG_SIZE = (1200, 630) +# مختصات دفتر مرکزی (اصفهان، ملاصدرا شمالی) — همان مقدار فرانت‌اند +# (frontend/src/data/mapProjects.ts: OFFICE_LOCATION)؛ در اسکیمای LocalBusiness +# به geo و hasMap تبدیل می‌شود. دو مقدار باید همیشه یکی بمانند. +OFFICE_GEO = (32.61764688216492, 51.666306342927214) +OFFICE_MAPS_URL = f"https://www.google.com/maps/search/?api=1&query={OFFICE_GEO[0]},{OFFICE_GEO[1]}" # کلید IndexNow — اثبات مالکیت برای اعلام فوری نشانی‌های سایت به Bing/Copilot و # موتورهای مشارکت‌کننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو می‌شود) INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614" @@ -90,8 +95,10 @@ def _fa_path(path: str) -> str: def _url_escape(url: str) -> str: - """درصدگذاری نویسه‌های غیر-ASCII (اسلاگ‌های فارسی) — استاندارد sitemap و IndexNow.""" - return quote(url, safe=":/?#[]@!$&'()*+,;=") + """درصدگذاری نویسه‌های غیر-ASCII و ناامن (اسلاگ‌های فارسی، فاصله و «?» در + اسلاگ انگلیسی) — استاندارد sitemap و IndexNow. ? و # عمداً «امن» نیستند: + در اسلاگِ خام، شروع query/fragment می‌شوند و URL را می‌شکنند.""" + return quote(url, safe=":/[]@!$&'()*+,;=") def _clip(text: str, limit: int) -> str: @@ -164,24 +171,33 @@ def _iso_stamp(value: datetime | None) -> str | None: # ---------- alternates — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ---------- -def _alternate_pairs(path: str, lang: str, has_en: bool) -> list[tuple[str, str]]: +def _alternate_pairs( + path: str, + lang: str, + has_en: bool, + fa_override: str = "", + en_override: str = "", +) -> list[tuple[str, str]]: """جفت‌های (hreflang، href) یک صفحه — مبنای مشترک head و sitemap. - صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد (لیست خالی).""" - fa = path if lang == "fa" else _fa_path(path) + صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد (لیست خالی). مقاله‌ها می‌توانند + در دو زبان اسلاگ متفاوت داشته باشند؛ overrideها همان نشانی واقعی‌اند.""" + fa = fa_override or (path if lang == "fa" else _fa_path(path)) if lang == "en" and not has_en: return [] + # نشانی‌ها خام برمی‌گردند — درصدگذاری URL با مصرف‌کننده است + # (_alternates برای head و url_entry برای sitemap) pairs = [("fa", _absolute(fa)), ("x-default", _absolute(fa))] if has_en: - en = _en_path(path) if lang == "fa" else path + en = en_override or (_en_path(path) if lang == "fa" else path) pairs.insert(1, ("en", _absolute(en))) return pairs -def _alternates(path: str, lang: str, has_en: bool = True) -> str: - """تگ‌های link alternate برای head — از همان جفت‌های sitemap.""" - pairs = _alternate_pairs(path, lang, has_en) +def _alternates(path: str, lang: str, has_en: bool = True, fa_override: str = "", en_override: str = "") -> str: + """تگ‌های link alternate برای head — از همان جفت‌های sitemap (درصدگذاری اینجا).""" + pairs = _alternate_pairs(path, lang, has_en, fa_override, en_override) return "\n ".join( - f'' + f'' for lang_code, href_ in pairs ) @@ -251,6 +267,10 @@ def _shell( path: str, lang: str = "fa", has_en: bool = True, + # نشانی‌های override برای مقاله‌هایی که دو زبان اسلاگ متفاوت دارند — + # پیش‌فرض همان مسیر با پیشوند /en است (اسلاگ مشترک) + alt_fa: str = "", + alt_en: str = "", og_image: str = "", og_image_alt: str = "", og_type: str = "website", @@ -264,9 +284,9 @@ def _shell( """index.html فرانت‌اند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND) description = description.strip() - canonical = _absolute(path) + canonical = _url_escape(_absolute(path)) esc = html.escape - alternates = _alternates(path, lang, has_en) + alternates = _alternates(path, lang, has_en, alt_fa, alt_en) # تصویر OG — تصویر اختصاصی صفحه یا تصویر پیش‌فرض برند custom_image = (og_image or "").strip() @@ -476,6 +496,14 @@ def _localbusiness_jsonld(db: Session, lang: str, path: str) -> dict | None: business["address"] = addresses[0] if len(addresses) == 1 else addresses if areas: business["areaServed"] = sorted(set(areas)) + # دادهٔ مکانی دقیق دفتر — سیگنال مستقیم برای گوگل و دستیارهای AI؛ + # hasMap همان قالب رسمی Maps URLs بدون کلید API است که فرانت‌اند هم می‌سازد + business["geo"] = { + "@type": "GeoCoordinates", + "latitude": OFFICE_GEO[0], + "longitude": OFFICE_GEO[1], + } + business["hasMap"] = OFFICE_MAPS_URL return business @@ -485,7 +513,7 @@ def _breadcrumb_jsonld(items: list[tuple[str, str]]) -> dict: "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ - {"@type": "ListItem", "position": i + 1, "name": name, "item": _absolute(url)} + {"@type": "ListItem", "position": i + 1, "name": name, "item": _url_escape(_absolute(url))} for i, (name, url) in enumerate(items) ], } @@ -633,7 +661,12 @@ def sitemap(db: Session = Depends(get_db)): } def entry(loc: str, lang: str, has_en: bool, lastmod: str | None = None) -> str: - pairs = _alternate_pairs(loc, lang, has_en) + """ورودی sitemap با جفت‌های alternateِ هم‌اسلاگ (صفحات استاتیک/پروژه‌ها).""" + return url_entry(loc, _alternate_pairs(loc, lang, has_en), lastmod) + + def url_entry(loc: str, pairs: list[tuple[str, str]], lastmod: str | None = None) -> str: + """ورودی sitemap با جفت‌های alternate صریح — برای مقاله‌هایی که دو زبان + اسلاگ جدا دارند.""" links = "\n".join( f' ' for code, href_ in pairs @@ -650,14 +683,22 @@ def sitemap(db: Session = Depends(get_db)): urls.append(entry(path, "fa", has_en)) if has_en: urls.append(entry(_en_path(path), "en", True)) - # مقاله‌ها و پروژه‌ها — alternate انگلیسی فقط با وجود ترجمهٔ واقعی + # مقاله‌ها — نشانی انگلیسی، اسلاگ انگلیسیِ خودش را دارد (اگر ثبت شده)؛ + # جفت‌های alternate صریح‌اند چون دو زبان می‌توانند اسلاگ متفاوت داشته باشند for slug, article in articles.items(): fa_path = f"/blog/{slug}" has_en = _article_has_en(article) stamp = _iso_stamp(article.updated_at) - urls.append(entry(fa_path, "fa", has_en, stamp)) if has_en: - urls.append(entry(_en_path(fa_path), "en", True, stamp)) + en_slug = (article.slug_en or "").strip() or slug + en_path = f"/en/blog/{en_slug}" + # مسیرهای خام — url_entry خودش درصدگذاری می‌کند (یک‌بار، نه دوبار) + pairs = [("fa", fa_path), ("en", en_path), ("x-default", fa_path)] + urls.append(url_entry(fa_path, pairs, stamp)) + urls.append(url_entry(en_path, pairs, stamp)) + else: + urls.append(entry(fa_path, "fa", False, stamp)) + # پروژه‌ها — اسلاگ مشترک دو زبان است for slug, project in projects.items(): fa_path = f"/projects/{slug}" has_en = _project_has_en(project) @@ -746,7 +787,7 @@ def llms_txt(db: Session = Depends(get_db)): summary = _clip(summary, 140) # بدون ترجمهٔ انگلیسی، همان نشانی فارسی اعلام می‌شود؛ توضیح خالی هم حذف می‌شود path = f"/en/projects/{p.slug}" if _project_has_en(p) else f"/projects/{p.slug}" - lines.append(f"- [{title}]({_absolute(path)})" + (f": {summary}" if summary else "")) + lines.append(f"- [{title}]({_url_escape(_absolute(path))})" + (f": {summary}" if summary else "")) latest = ( db.query(models.Article) .filter(models.Article.status == "published") @@ -761,8 +802,12 @@ def llms_txt(db: Session = Depends(get_db)): title = (title_en or a.title.strip()) + ("" if title_en else " (Persian)") summary = ((a.excerpt_en or "").strip() if title_en else "") or (a.excerpt or "").strip() summary = _clip(summary, 140) - path = f"/en/blog/{a.slug}" if _article_has_en(a) else f"/blog/{a.slug}" - lines.append(f"- [{title}]({_absolute(path)})" + (f": {summary}" if summary else "")) + # نشانی انگلیسی اسلاگ انگلیسی خودش را دارد (اگر ثبت شده باشد) + if _article_has_en(a): + path = f"/en/blog/{(a.slug_en or '').strip() or a.slug}" + else: + path = f"/blog/{a.slug}" + lines.append(f"- [{title}]({_url_escape(_absolute(path))})" + (f": {summary}" if summary else "")) brand_row = _brand_row(db) contact_lines = [] if brand_row and (brand_row.phone or "").strip(): @@ -776,6 +821,8 @@ def llms_txt(db: Session = Depends(get_db)): detail = street if (city and city in street) else (street + ("، " + city if city and street else city)) if detail: contact_lines.append(f"- Office: {_clip(detail, 120)}") + # مختصات و پیوند نقشهٔ دفتر مرکزی — دادهٔ مکانی مستقیم برای دستیارهای AI + contact_lines.append(f"- Head office on map: {OFFICE_MAPS_URL}") if contact_lines: lines += ["", "## Contact", *contact_lines] # راهنمای دستیارهای AI: تصاویر هر پروژه به‌صورت آرشیو ZIP با manifest قابل دریافت است @@ -894,6 +941,31 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str: items += f"
  • {line}
  • " parts.append(f"

    {'Our Projects' if lang == 'en' else 'پروژه‌های ما'}

    ") + # مقاله‌های منتشرشده — فهرست وبلاگ برای خزنده‌های بدون جاوااسکریپت (ربات‌های AI)؛ + # نشانی انگلیسی همان اسلاگ انگلیسی مقاله است (اگر ثبت شده باشد) + if page_key in ("home", "blog"): + blog_items = ( + db.query(models.Article) + .filter(models.Article.status == "published") + .order_by(models.Article.position, models.Article.id) + .limit(6) + .all() + ) + if blog_items: + items = "" + for a in blog_items: + title = en(a.title, a.title_en) + if lang == "en" and _article_has_en(a): + href = f"/en/blog/{quote((a.slug_en or '').strip() or a.slug, safe='')}" + else: + href = f"/blog/{quote(a.slug, safe='')}" + items += f'
  • {esc(title)}' + summary = en(a.excerpt, a.excerpt_en) + if summary: + items += f": {esc(_clip(summary, 160))}" + items += "
  • " + parts.append(f"

    {'Blog' if lang == 'en' else 'وبلاگ'}

    ") + if page_key in ("home", "services"): services = db.query(models.Service).order_by(models.Service.position, models.Service.id).all() if services: @@ -921,13 +993,15 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str: ) parts.append(f"

    {'Frequently Asked Questions' if lang == 'en' else 'پرسش‌های پرتکرار'}

    ") - if page_key == "about": - team = db.query(models.TeamMember).order_by(models.TeamMember.position).all() - if team: - items = "".join( - f"
  • {esc(en(m.name, m.name_en))} — {esc(en(m.role, m.role_en))}
  • " for m in team - ) - parts.append(f"

    {'Our Team' if lang == 'en' else 'تیم ما'}

    ") + # بخش «تیم ما» موقتاً از خروجی حذف شده — همراه صفحهٔ درباره در فرانت‌اند غیرفعال شد + # تا خزنده‌ها و دستیارهای AI هم چیزی از آن نبینند؛ برای بازگردانی این بلوک را دوباره فعال کنید + # if page_key == "about": + # team = db.query(models.TeamMember).order_by(models.TeamMember.position).all() + # if team: + # items = "".join( + # f"
  • {esc(en(m.name, m.name_en))} — {esc(en(m.role, m.role_en))}
  • " for m in team + # ) + # parts.append(f"

    {'Our Team' if lang == 'en' else 'تیم ما'}

    ") if page_key in ("home", "contact"): offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all() @@ -945,6 +1019,11 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str: hours = en(o.hours, o.hours_en) detail = addr if (city and city in addr) else f"{addr}، {city}" if addr and city else (addr or city) contact_bits.append(f"
  • {esc(detail)}" + (f" — {esc(hours)}" if hours else "") + "
  • ") + # موقعیت روی نقشه — خزنده‌های بدون جاوااسکریپت (ربات‌های AI) هم مختصات و پیوند را می‌بینند + map_label = "Location on map" if lang == "en" else "موقعیت روی نقشه" + contact_bits.append( + f'
  • {map_label}: {OFFICE_GEO[0]}, {OFFICE_GEO[1]}
  • ' + ) if contact_bits: parts.append(f"

    {'Contact' if lang == 'en' else 'تماس'}

    ") @@ -1087,6 +1166,14 @@ def blog_detail(slug: str, db: Session = Depends(get_db)): .first() ) if article is None: + # اسلاگ انگلیسی روی مسیر فارسی → نشانی canonical انگلیسی همان مقاله + by_en = ( + db.query(models.Article) + .filter(models.Article.slug_en == slug, models.Article.status == "published") + .first() + ) + if by_en is not None: + return RedirectResponse(url=f"/en/blog/{by_en.slug_en}", status_code=301) # مقاله ناموجود یا پیش‌نویس — کد وضعیت ۴۰۴ تا خزنده صفحه را ایندکس نکند؛ # SPA همان پیام «یافت نشد» را نشان می‌دهد return _shell( @@ -1114,6 +1201,9 @@ def blog_detail(slug: str, db: Session = Depends(get_db)): description=_meta_desc_for(article, "fa"), path=f"/blog/{article.slug}", has_en=_article_has_en(article), + # alternate انگلیسی با اسلاگ انگلیسی خودش — نه اسلاگ فارسی زیر /en + # (خام پاس می‌شود؛ _url_escape خودش یک‌بار درصدگذاری می‌کند) + alt_en=f"/en/blog/{(article.slug_en or '').strip() or article.slug}", og_image=article.image, og_image_alt=article.image_alt or title, og_type="article", @@ -1328,6 +1418,9 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse: path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}", lang="en", has_en=_article_has_en(article), + # alternate فارسی با اسلاگ فارسی خودش — نه حذف پیشوند /en از مسیر انگلیسی + # (خام پاس می‌شود؛ _url_escape خودش یک‌بار درصدگذاری می‌کند) + alt_fa=f"/blog/{article.slug}", og_image=article.image, og_image_alt=article.image_alt or title, og_type="article", @@ -1361,6 +1454,14 @@ def en_blog_detail_route(slug: str, db: Session = Depends(get_db)): .first() ) if article is None: + # اسلاگ فارسی روی مسیر انگلیسی → مقالهٔ همان اسلاگ با slug_en → ۳۰۱ به canonical + by_fa = ( + db.query(models.Article) + .filter(models.Article.slug == slug, models.Article.status == "published") + .first() + ) + if by_fa is not None and (by_fa.slug_en or "").strip(): + return RedirectResponse(url=f"/en/blog/{by_fa.slug_en}", status_code=301) return _shell( db=db, title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en", diff --git a/frontend/src/components/LangToggle.tsx b/frontend/src/components/LangToggle.tsx index c85a56d..3271d19 100644 --- a/frontend/src/components/LangToggle.tsx +++ b/frontend/src/components/LangToggle.tsx @@ -1,13 +1,31 @@ import { useLocation, useNavigate } from 'react-router-dom' import { LanguagesIcon } from 'lucide-react' import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang' +import { useSite } from '../api' + +/** مسیر مقابل صفحهٔ مقاله — اسلاگ فارسی/انگلیسی مقاله فرق دارند و پیشوند + * سادهٔ /en نشانی غلط (۴۰۴) می‌سازد؛ همین‌جا نگاشت درست انجام می‌شود */ +function articleCounterpart(pathname: string, articles: { slug: string; slug_en?: string }[]): string | null { + const fa = pathname.match(/^\/blog\/([^/]+)$/) + const en = pathname.match(/^\/en\/blog\/([^/]+)$/) + if (!fa && !en) return null + const current = decodeURIComponent((fa ?? en)![1]) + const article = articles.find((a) => a.slug === current || a.slug_en === current) + if (!article) return null + if (fa) { + const enSlug = article.slug_en?.trim() + return enSlug ? `/en/blog/${encodeURIComponent(enSlug)}` : pathname === '/blog' ? '/en/blog' : `/en${pathname}` + } + return `/blog/${encodeURIComponent(article.slug)}` +} /** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */ export function LangToggle({ className = '' }: { className?: string }) { const { lang, t } = useLang() const navigate = useNavigate() const { pathname } = useLocation() - const target = counterpartPath(pathname, lang) + const { articles } = useSite() + const target = articleCounterpart(pathname, articles) ?? counterpartPath(pathname, lang) const switchLang = () => { writeLangCookie(lang === 'fa' ? 'en' : 'fa') diff --git a/frontend/src/pages/ArticleDetail.tsx b/frontend/src/pages/ArticleDetail.tsx index 39d2bb0..6dcddfd 100644 --- a/frontend/src/pages/ArticleDetail.tsx +++ b/frontend/src/pages/ArticleDetail.tsx @@ -11,6 +11,7 @@ import { TableOfContents, extractToc, type TocItem } from '../components/TableOf import { Newsletter } from '../components/home/Newsletter'; import { useSite } from '../api'; import { useLang } from '../i18n/lang'; +import { useAdmin } from '../admin/AdminContext'; import { applySeo } from '../seo'; import { useEffect } from 'react'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; @@ -44,12 +45,16 @@ export function ArticleDetail() { const { slug } = useParams<{slug: string;}>(); const { articles, brand } = useSite(); const { t, href, lang } = useLang(); - const article = articles.find((a) => a.slug === slug); + const { isAuthenticated } = useAdmin(); + const article = articles.find((a) => a.slug === slug || (!!a.slug_en && a.slug_en === slug)); // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — هم‌ارز نسخهٔ سمت سرور (seo_pages.py) useEffect(() => { if (!article) return; - const pageUrl = new URL(href(`/blog/${article.slug}`), window.location.origin).href; + // نشانی خود مقاله — در انگلیسی همان اسلاگ انگلیسی است تا canonical با آدرس + // واقعی مرورگر یکی بماند (نویسنده از «دیدن در سایت» با همان اسلاگ می‌آید) + const pathSlug = lang === 'en' && article.slug_en ? article.slug_en : article.slug; + const pageUrl = new URL(href(`/blog/${pathSlug}`), window.location.origin).href; const absolute = (path: string) => new URL(path, window.location.origin).href; const authorName = (article.author ?? '').trim(); const description = article.meta_description || article.excerpt; @@ -93,12 +98,15 @@ export function ArticleDetail() { title: `${article.title} | ${brand.name}`, // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده description, - path: href(`/blog/${article.slug}`), + path: href(`/blog/${encodeURIComponent(pathSlug)}`), image: article.image, type: 'article', lang, // alternate انگلیسی فقط با وجود ترجمهٔ واقعی hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)), + // اسلاگ دو زبان فرق دارد — مسیرهای صریح تا hreflang بعد از هیدریشن درست بماند + altFa: `/blog/${encodeURIComponent(article.slug)}`, + altEn: article.slug_en?.trim() ? `/en/blog/${encodeURIComponent(article.slug_en.trim())}` : undefined, jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])], }); }, [article?.id, lang, href]); @@ -163,6 +171,17 @@ export function ArticleDetail() { {/* نویسنده، تاریخ جلالی و زمان مطالعه */} + + {/* میان‌بر ویرایش پیش‌نویس برای کارمند واردشده — تا از خود صفحه هم + بدون برگشت به فهرست، ویرایشگر باز شود */} + {isAuthenticated && article.status === 'draft' && ( + + {dir === 'ltr' ? 'Edit draft' : 'ویرایش پیش‌نویس'} + + )} diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index e430af5..7f55f4a 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -64,7 +64,7 @@ function removeAlternate(hreflang: string): void { /** لینک‌های alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر. * مجموعهٔ کامل (fa/en/x-default) فقط وقتی اعلام می‌شود که ترجمهٔ انگلیسی واقعاً * وجود داشته باشد؛ مجموعهٔ ناسازگار کل hreflang را بی‌اعتبار می‌کند. */ -function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void { +function upsertAlternates(path: string, lang: Lang, hasEn: boolean, altFa?: string, altEn?: string): void { // صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد؛ صفحهٔ فارسی فقط خودش و x-default const targets: [Lang, string][] = hasEn ? [['fa', 'fa'], ['en', 'en'], ['fa', 'x-default']] @@ -73,8 +73,9 @@ function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void { : [] if (!hasEn) removeAlternate('en') for (const [targetLang, hreflang] of targets) { - // x-default همیشه نسخه فارسی است - const target = localeHref(path, targetLang) + // x-default همیشه نسخه فارسی است؛ مسیر صریح مقاله بر پیشوند ساده ترجیح دارد + const explicit = hreflang === 'fa' || hreflang === 'x-default' ? altFa : hreflang === 'en' ? altEn : undefined + const target = explicit ?? localeHref(path, targetLang) const attr = `data-hreflang="${hreflang}"` let el = document.querySelector(`link[${attr}]`) if (!el) { @@ -118,10 +119,17 @@ export type SeoInput = { hasEn?: boolean /** اسکیمای JSON-LD — یک شیء یا آرایه‌ای از چند اسکیما (مثل Article + Breadcrumb + FAQPage) */ jsonLd?: Record | Record[] | null + /** مسیر alternate فارسی/انگلیسی صریح — برای مقالات که اسلاگ دو زبانشان فرق دارد */ + altFa?: string + altEn?: string } /** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */ export function applySeo(input: SeoInput): void { + // alternateهای صریح (مقالات: اسلاگ fa/en فرق دارد) — بدون این، localeHref + // نشانی غلط می‌سازد و hreflang درست سرور را بعد از هیدریشن خراب می‌کند + const altFa = input.altFa?.trim() + const altEn = input.altEn?.trim() const title = input.title.trim() const description = (input.description ?? '').trim() const path = input.path ?? window.location.pathname @@ -156,6 +164,6 @@ export function applySeo(input: SeoInput): void { upsertMeta('property', 'og:image:height', '630') } } - upsertAlternates(path, lang, hasEn) + upsertAlternates(path, lang, hasEn, altFa, altEn) setJsonLd(input.jsonLd ?? null) }