fix(blog): کلید زبان در صفحهٔ مقاله ۴۰۴ میداد + اسلاگهای انگلیسی نامعتبر
باگ کاربر: در پیشنمایش فارسی مقاله، دکمهٔ 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 با فاصله هرگز کار نمیکرد
This commit is contained in:
+129
-28
@@ -32,7 +32,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends, Response
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
@@ -50,6 +50,11 @@ FALLBACK_EN_BRAND = "Aramesh Construction Group"
|
|||||||
# تصویر پیشفرض شبکههای اجتماعی — در frontend/public بیلد میشود
|
# تصویر پیشفرض شبکههای اجتماعی — در frontend/public بیلد میشود
|
||||||
DEFAULT_OG_IMAGE = "/og-default.jpg"
|
DEFAULT_OG_IMAGE = "/og-default.jpg"
|
||||||
DEFAULT_OG_SIZE = (1200, 630)
|
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 و
|
# کلید IndexNow — اثبات مالکیت برای اعلام فوری نشانیهای سایت به Bing/Copilot و
|
||||||
# موتورهای مشارکتکننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو میشود)
|
# موتورهای مشارکتکننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو میشود)
|
||||||
INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614"
|
INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614"
|
||||||
@@ -90,8 +95,10 @@ def _fa_path(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _url_escape(url: str) -> str:
|
def _url_escape(url: str) -> str:
|
||||||
"""درصدگذاری نویسههای غیر-ASCII (اسلاگهای فارسی) — استاندارد sitemap و IndexNow."""
|
"""درصدگذاری نویسههای غیر-ASCII و ناامن (اسلاگهای فارسی، فاصله و «?» در
|
||||||
return quote(url, safe=":/?#[]@!$&'()*+,;=")
|
اسلاگ انگلیسی) — استاندارد sitemap و IndexNow. ? و # عمداً «امن» نیستند:
|
||||||
|
در اسلاگِ خام، شروع query/fragment میشوند و URL را میشکنند."""
|
||||||
|
return quote(url, safe=":/[]@!$&'()*+,;=")
|
||||||
|
|
||||||
|
|
||||||
def _clip(text: str, limit: int) -> str:
|
def _clip(text: str, limit: int) -> str:
|
||||||
@@ -164,24 +171,33 @@ def _iso_stamp(value: datetime | None) -> str | None:
|
|||||||
# ---------- alternates — فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد ----------
|
# ---------- 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.
|
"""جفتهای (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:
|
if lang == "en" and not has_en:
|
||||||
return []
|
return []
|
||||||
|
# نشانیها خام برمیگردند — درصدگذاری URL با مصرفکننده است
|
||||||
|
# (_alternates برای head و url_entry برای sitemap)
|
||||||
pairs = [("fa", _absolute(fa)), ("x-default", _absolute(fa))]
|
pairs = [("fa", _absolute(fa)), ("x-default", _absolute(fa))]
|
||||||
if has_en:
|
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)))
|
pairs.insert(1, ("en", _absolute(en)))
|
||||||
return pairs
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
def _alternates(path: str, lang: str, has_en: bool = True) -> str:
|
def _alternates(path: str, lang: str, has_en: bool = True, fa_override: str = "", en_override: str = "") -> str:
|
||||||
"""تگهای link alternate برای head — از همان جفتهای sitemap."""
|
"""تگهای link alternate برای head — از همان جفتهای sitemap (درصدگذاری اینجا)."""
|
||||||
pairs = _alternate_pairs(path, lang, has_en)
|
pairs = _alternate_pairs(path, lang, has_en, fa_override, en_override)
|
||||||
return "\n ".join(
|
return "\n ".join(
|
||||||
f'<link rel="alternate" hreflang="{lang_code}" href="{html.escape(href_)}" />'
|
f'<link rel="alternate" hreflang="{lang_code}" href="{html.escape(_url_escape(href_))}" />'
|
||||||
for lang_code, href_ in pairs
|
for lang_code, href_ in pairs
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -251,6 +267,10 @@ def _shell(
|
|||||||
path: str,
|
path: str,
|
||||||
lang: str = "fa",
|
lang: str = "fa",
|
||||||
has_en: bool = True,
|
has_en: bool = True,
|
||||||
|
# نشانیهای override برای مقالههایی که دو زبان اسلاگ متفاوت دارند —
|
||||||
|
# پیشفرض همان مسیر با پیشوند /en است (اسلاگ مشترک)
|
||||||
|
alt_fa: str = "",
|
||||||
|
alt_en: str = "",
|
||||||
og_image: str = "",
|
og_image: str = "",
|
||||||
og_image_alt: str = "",
|
og_image_alt: str = "",
|
||||||
og_type: str = "website",
|
og_type: str = "website",
|
||||||
@@ -264,9 +284,9 @@ def _shell(
|
|||||||
"""index.html فرانتاند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
"""index.html فرانتاند با متای تزریقی؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||||
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
||||||
description = description.strip()
|
description = description.strip()
|
||||||
canonical = _absolute(path)
|
canonical = _url_escape(_absolute(path))
|
||||||
esc = html.escape
|
esc = html.escape
|
||||||
alternates = _alternates(path, lang, has_en)
|
alternates = _alternates(path, lang, has_en, alt_fa, alt_en)
|
||||||
|
|
||||||
# تصویر OG — تصویر اختصاصی صفحه یا تصویر پیشفرض برند
|
# تصویر OG — تصویر اختصاصی صفحه یا تصویر پیشفرض برند
|
||||||
custom_image = (og_image or "").strip()
|
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
|
business["address"] = addresses[0] if len(addresses) == 1 else addresses
|
||||||
if areas:
|
if areas:
|
||||||
business["areaServed"] = sorted(set(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
|
return business
|
||||||
|
|
||||||
|
|
||||||
@@ -485,7 +513,7 @@ def _breadcrumb_jsonld(items: list[tuple[str, str]]) -> dict:
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "BreadcrumbList",
|
"@type": "BreadcrumbList",
|
||||||
"itemListElement": [
|
"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)
|
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:
|
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(
|
links = "\n".join(
|
||||||
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />'
|
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />'
|
||||||
for code, href_ in pairs
|
for code, href_ in pairs
|
||||||
@@ -650,14 +683,22 @@ def sitemap(db: Session = Depends(get_db)):
|
|||||||
urls.append(entry(path, "fa", has_en))
|
urls.append(entry(path, "fa", has_en))
|
||||||
if has_en:
|
if has_en:
|
||||||
urls.append(entry(_en_path(path), "en", True))
|
urls.append(entry(_en_path(path), "en", True))
|
||||||
# مقالهها و پروژهها — alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
# مقالهها — نشانی انگلیسی، اسلاگ انگلیسیِ خودش را دارد (اگر ثبت شده)؛
|
||||||
|
# جفتهای alternate صریحاند چون دو زبان میتوانند اسلاگ متفاوت داشته باشند
|
||||||
for slug, article in articles.items():
|
for slug, article in articles.items():
|
||||||
fa_path = f"/blog/{slug}"
|
fa_path = f"/blog/{slug}"
|
||||||
has_en = _article_has_en(article)
|
has_en = _article_has_en(article)
|
||||||
stamp = _iso_stamp(article.updated_at)
|
stamp = _iso_stamp(article.updated_at)
|
||||||
urls.append(entry(fa_path, "fa", has_en, stamp))
|
|
||||||
if has_en:
|
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():
|
for slug, project in projects.items():
|
||||||
fa_path = f"/projects/{slug}"
|
fa_path = f"/projects/{slug}"
|
||||||
has_en = _project_has_en(project)
|
has_en = _project_has_en(project)
|
||||||
@@ -746,7 +787,7 @@ def llms_txt(db: Session = Depends(get_db)):
|
|||||||
summary = _clip(summary, 140)
|
summary = _clip(summary, 140)
|
||||||
# بدون ترجمهٔ انگلیسی، همان نشانی فارسی اعلام میشود؛ توضیح خالی هم حذف میشود
|
# بدون ترجمهٔ انگلیسی، همان نشانی فارسی اعلام میشود؛ توضیح خالی هم حذف میشود
|
||||||
path = f"/en/projects/{p.slug}" if _project_has_en(p) else f"/projects/{p.slug}"
|
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 = (
|
latest = (
|
||||||
db.query(models.Article)
|
db.query(models.Article)
|
||||||
.filter(models.Article.status == "published")
|
.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)")
|
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 = ((a.excerpt_en or "").strip() if title_en else "") or (a.excerpt or "").strip()
|
||||||
summary = _clip(summary, 140)
|
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)
|
brand_row = _brand_row(db)
|
||||||
contact_lines = []
|
contact_lines = []
|
||||||
if brand_row and (brand_row.phone or "").strip():
|
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))
|
detail = street if (city and city in street) else (street + ("، " + city if city and street else city))
|
||||||
if detail:
|
if detail:
|
||||||
contact_lines.append(f"- Office: {_clip(detail, 120)}")
|
contact_lines.append(f"- Office: {_clip(detail, 120)}")
|
||||||
|
# مختصات و پیوند نقشهٔ دفتر مرکزی — دادهٔ مکانی مستقیم برای دستیارهای AI
|
||||||
|
contact_lines.append(f"- Head office on map: {OFFICE_MAPS_URL}")
|
||||||
if contact_lines:
|
if contact_lines:
|
||||||
lines += ["", "## Contact", *contact_lines]
|
lines += ["", "## Contact", *contact_lines]
|
||||||
# راهنمای دستیارهای AI: تصاویر هر پروژه بهصورت آرشیو ZIP با manifest قابل دریافت است
|
# راهنمای دستیارهای AI: تصاویر هر پروژه بهصورت آرشیو ZIP با manifest قابل دریافت است
|
||||||
@@ -894,6 +941,31 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
|
|||||||
items += f"<li>{line}</li>"
|
items += f"<li>{line}</li>"
|
||||||
parts.append(f"<h2>{'Our Projects' if lang == 'en' else 'پروژههای ما'}</h2><ul>{items}</ul>")
|
parts.append(f"<h2>{'Our Projects' if lang == 'en' else 'پروژههای ما'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
|
# مقالههای منتشرشده — فهرست وبلاگ برای خزندههای بدون جاوااسکریپت (رباتهای 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'<li><a href="{esc(_absolute(href))}">{esc(title)}</a>'
|
||||||
|
summary = en(a.excerpt, a.excerpt_en)
|
||||||
|
if summary:
|
||||||
|
items += f": {esc(_clip(summary, 160))}"
|
||||||
|
items += "</li>"
|
||||||
|
parts.append(f"<h2>{'Blog' if lang == 'en' else 'وبلاگ'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
if page_key in ("home", "services"):
|
if page_key in ("home", "services"):
|
||||||
services = db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
services = db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
||||||
if services:
|
if services:
|
||||||
@@ -921,13 +993,15 @@ def _site_content_html(db: Session, page_key: str, lang: str) -> str:
|
|||||||
)
|
)
|
||||||
parts.append(f"<h2>{'Frequently Asked Questions' if lang == 'en' else 'پرسشهای پرتکرار'}</h2><ul>{items}</ul>")
|
parts.append(f"<h2>{'Frequently Asked Questions' if lang == 'en' else 'پرسشهای پرتکرار'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
if page_key == "about":
|
# بخش «تیم ما» موقتاً از خروجی حذف شده — همراه صفحهٔ درباره در فرانتاند غیرفعال شد
|
||||||
team = db.query(models.TeamMember).order_by(models.TeamMember.position).all()
|
# تا خزندهها و دستیارهای AI هم چیزی از آن نبینند؛ برای بازگردانی این بلوک را دوباره فعال کنید
|
||||||
if team:
|
# if page_key == "about":
|
||||||
items = "".join(
|
# team = db.query(models.TeamMember).order_by(models.TeamMember.position).all()
|
||||||
f"<li><strong>{esc(en(m.name, m.name_en))}</strong> — {esc(en(m.role, m.role_en))}</li>" for m in team
|
# if team:
|
||||||
)
|
# items = "".join(
|
||||||
parts.append(f"<h2>{'Our Team' if lang == 'en' else 'تیم ما'}</h2><ul>{items}</ul>")
|
# f"<li><strong>{esc(en(m.name, m.name_en))}</strong> — {esc(en(m.role, m.role_en))}</li>" for m in team
|
||||||
|
# )
|
||||||
|
# parts.append(f"<h2>{'Our Team' if lang == 'en' else 'تیم ما'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
if page_key in ("home", "contact"):
|
if page_key in ("home", "contact"):
|
||||||
offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all()
|
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)
|
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)
|
detail = addr if (city and city in addr) else f"{addr}، {city}" if addr and city else (addr or city)
|
||||||
contact_bits.append(f"<li>{esc(detail)}" + (f" — {esc(hours)}" if hours else "") + "</li>")
|
contact_bits.append(f"<li>{esc(detail)}" + (f" — {esc(hours)}" if hours else "") + "</li>")
|
||||||
|
# موقعیت روی نقشه — خزندههای بدون جاوااسکریپت (رباتهای AI) هم مختصات و پیوند را میبینند
|
||||||
|
map_label = "Location on map" if lang == "en" else "موقعیت روی نقشه"
|
||||||
|
contact_bits.append(
|
||||||
|
f'<li>{map_label}: <a href="{OFFICE_MAPS_URL}">{OFFICE_GEO[0]}, {OFFICE_GEO[1]}</a></li>'
|
||||||
|
)
|
||||||
if contact_bits:
|
if contact_bits:
|
||||||
parts.append(f"<h2>{'Contact' if lang == 'en' else 'تماس'}</h2><ul>{''.join(contact_bits)}</ul>")
|
parts.append(f"<h2>{'Contact' if lang == 'en' else 'تماس'}</h2><ul>{''.join(contact_bits)}</ul>")
|
||||||
|
|
||||||
@@ -1087,6 +1166,14 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if article is None:
|
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 همان پیام «یافت نشد» را نشان میدهد
|
# SPA همان پیام «یافت نشد» را نشان میدهد
|
||||||
return _shell(
|
return _shell(
|
||||||
@@ -1114,6 +1201,9 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
description=_meta_desc_for(article, "fa"),
|
description=_meta_desc_for(article, "fa"),
|
||||||
path=f"/blog/{article.slug}",
|
path=f"/blog/{article.slug}",
|
||||||
has_en=_article_has_en(article),
|
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=article.image,
|
||||||
og_image_alt=article.image_alt or title,
|
og_image_alt=article.image_alt or title,
|
||||||
og_type="article",
|
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}",
|
path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}",
|
||||||
lang="en",
|
lang="en",
|
||||||
has_en=_article_has_en(article),
|
has_en=_article_has_en(article),
|
||||||
|
# alternate فارسی با اسلاگ فارسی خودش — نه حذف پیشوند /en از مسیر انگلیسی
|
||||||
|
# (خام پاس میشود؛ _url_escape خودش یکبار درصدگذاری میکند)
|
||||||
|
alt_fa=f"/blog/{article.slug}",
|
||||||
og_image=article.image,
|
og_image=article.image,
|
||||||
og_image_alt=article.image_alt or title,
|
og_image_alt=article.image_alt or title,
|
||||||
og_type="article",
|
og_type="article",
|
||||||
@@ -1361,6 +1454,14 @@ def en_blog_detail_route(slug: str, db: Session = Depends(get_db)):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if article is None:
|
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(
|
return _shell(
|
||||||
db=db,
|
db=db,
|
||||||
title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en",
|
title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en",
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { LanguagesIcon } from 'lucide-react'
|
import { LanguagesIcon } from 'lucide-react'
|
||||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
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 انگلیسی همان صفحه جابهجا میشود */
|
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابهجا میشود */
|
||||||
export function LangToggle({ className = '' }: { className?: string }) {
|
export function LangToggle({ className = '' }: { className?: string }) {
|
||||||
const { lang, t } = useLang()
|
const { lang, t } = useLang()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const target = counterpartPath(pathname, lang)
|
const { articles } = useSite()
|
||||||
|
const target = articleCounterpart(pathname, articles) ?? counterpartPath(pathname, lang)
|
||||||
|
|
||||||
const switchLang = () => {
|
const switchLang = () => {
|
||||||
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { TableOfContents, extractToc, type TocItem } from '../components/TableOf
|
|||||||
import { Newsletter } from '../components/home/Newsletter';
|
import { Newsletter } from '../components/home/Newsletter';
|
||||||
import { useSite } from '../api';
|
import { useSite } from '../api';
|
||||||
import { useLang } from '../i18n/lang';
|
import { useLang } from '../i18n/lang';
|
||||||
|
import { useAdmin } from '../admin/AdminContext';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo } from '../seo';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||||
@@ -44,12 +45,16 @@ export function ArticleDetail() {
|
|||||||
const { slug } = useParams<{slug: string;}>();
|
const { slug } = useParams<{slug: string;}>();
|
||||||
const { articles, brand } = useSite();
|
const { articles, brand } = useSite();
|
||||||
const { t, href, lang } = useLang();
|
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)
|
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — همارز نسخهٔ سمت سرور (seo_pages.py)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
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 absolute = (path: string) => new URL(path, window.location.origin).href;
|
||||||
const authorName = (article.author ?? '').trim();
|
const authorName = (article.author ?? '').trim();
|
||||||
const description = article.meta_description || article.excerpt;
|
const description = article.meta_description || article.excerpt;
|
||||||
@@ -93,12 +98,15 @@ export function ArticleDetail() {
|
|||||||
title: `${article.title} | ${brand.name}`,
|
title: `${article.title} | ${brand.name}`,
|
||||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||||
description,
|
description,
|
||||||
path: href(`/blog/${article.slug}`),
|
path: href(`/blog/${encodeURIComponent(pathSlug)}`),
|
||||||
image: article.image,
|
image: article.image,
|
||||||
type: 'article',
|
type: 'article',
|
||||||
lang,
|
lang,
|
||||||
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
||||||
hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)),
|
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] : [])],
|
jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])],
|
||||||
});
|
});
|
||||||
}, [article?.id, lang, href]);
|
}, [article?.id, lang, href]);
|
||||||
@@ -163,6 +171,17 @@ export function ArticleDetail() {
|
|||||||
|
|
||||||
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
|
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
|
||||||
<ArticleMeta article={article} className="mt-7" />
|
<ArticleMeta article={article} className="mt-7" />
|
||||||
|
|
||||||
|
{/* میانبر ویرایش پیشنویس برای کارمند واردشده — تا از خود صفحه هم
|
||||||
|
بدون برگشت به فهرست، ویرایشگر باز شود */}
|
||||||
|
{isAuthenticated && article.status === 'draft' && (
|
||||||
|
<Link
|
||||||
|
to={`/admin/write/${article.id}`}
|
||||||
|
className="mt-5 inline-block border border-copper/60 bg-ink/90 px-3 py-1.5 text-xs font-bold text-copper backdrop-blur transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||||
|
>
|
||||||
|
{dir === 'ltr' ? 'Edit draft' : 'ویرایش پیشنویس'}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -64,7 +64,7 @@ function removeAlternate(hreflang: string): void {
|
|||||||
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر.
|
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر.
|
||||||
* مجموعهٔ کامل (fa/en/x-default) فقط وقتی اعلام میشود که ترجمهٔ انگلیسی واقعاً
|
* مجموعهٔ کامل (fa/en/x-default) فقط وقتی اعلام میشود که ترجمهٔ انگلیسی واقعاً
|
||||||
* وجود داشته باشد؛ مجموعهٔ ناسازگار کل hreflang را بیاعتبار میکند. */
|
* وجود داشته باشد؛ مجموعهٔ ناسازگار کل 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
|
// صفحهٔ انگلیسیِ ترجمهنشده هیچ ادعایی ندارد؛ صفحهٔ فارسی فقط خودش و x-default
|
||||||
const targets: [Lang, string][] = hasEn
|
const targets: [Lang, string][] = hasEn
|
||||||
? [['fa', 'fa'], ['en', 'en'], ['fa', 'x-default']]
|
? [['fa', 'fa'], ['en', 'en'], ['fa', 'x-default']]
|
||||||
@@ -73,8 +73,9 @@ function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void {
|
|||||||
: []
|
: []
|
||||||
if (!hasEn) removeAlternate('en')
|
if (!hasEn) removeAlternate('en')
|
||||||
for (const [targetLang, hreflang] of targets) {
|
for (const [targetLang, hreflang] of targets) {
|
||||||
// x-default همیشه نسخه فارسی است
|
// x-default همیشه نسخه فارسی است؛ مسیر صریح مقاله بر پیشوند ساده ترجیح دارد
|
||||||
const target = localeHref(path, targetLang)
|
const explicit = hreflang === 'fa' || hreflang === 'x-default' ? altFa : hreflang === 'en' ? altEn : undefined
|
||||||
|
const target = explicit ?? localeHref(path, targetLang)
|
||||||
const attr = `data-hreflang="${hreflang}"`
|
const attr = `data-hreflang="${hreflang}"`
|
||||||
let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`)
|
let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`)
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -118,10 +119,17 @@ export type SeoInput = {
|
|||||||
hasEn?: boolean
|
hasEn?: boolean
|
||||||
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + Breadcrumb + FAQPage) */
|
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + Breadcrumb + FAQPage) */
|
||||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
||||||
|
/** مسیر alternate فارسی/انگلیسی صریح — برای مقالات که اسلاگ دو زبانشان فرق دارد */
|
||||||
|
altFa?: string
|
||||||
|
altEn?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
||||||
export function applySeo(input: SeoInput): void {
|
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 title = input.title.trim()
|
||||||
const description = (input.description ?? '').trim()
|
const description = (input.description ?? '').trim()
|
||||||
const path = input.path ?? window.location.pathname
|
const path = input.path ?? window.location.pathname
|
||||||
@@ -156,6 +164,6 @@ export function applySeo(input: SeoInput): void {
|
|||||||
upsertMeta('property', 'og:image:height', '630')
|
upsertMeta('property', 'og:image:height', '630')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
upsertAlternates(path, lang, hasEn)
|
upsertAlternates(path, lang, hasEn, altFa, altEn)
|
||||||
setJsonLd(input.jsonLd ?? null)
|
setJsonLd(input.jsonLd ?? null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user