feat(seo): ایندکسپذیری کامل، محتوای سمت سرور برای خزندههای AI و رفع باگ ایندکس
سئوی سمت سرور (backend/app/seo_pages.py) - متا/canonical/hreflang/JSON-LD برای همهٔ صفحات عمومی (شش صفحهٔ استاتیک فارسی که قبلاً پوستهٔ خالی بودند) - canonical سمت سرور (قبلاً فقط با JS ست میشد)، ۴۰۴ واقعی برای مسیرهای ناموجود - اسکیما: Organization با لوگوی ImageObject، FAQPage، LocalBusiness، BreadcrumbList، CreativeWork پروژه با همهٔ تصاویر بهصورت ImageObject (نام/توضیح + موقعیت پروژه) - hreflang فقط وقتی ترجمهٔ انگلیسی واقعاً وجود دارد؛ sitemap با URLهای درصدگذاریشده - llms.txt و robots.txt تازهساز از دیتابیس؛ سیاست صریح خزندههای AI + Allow: /api/site - _site_content_html: محتوای واقعی سایت بهصورت HTML خوانا در <noscript> — خزندههای بدون جاوااسکریپت (GPTBot/OAI-SearchBot/ClaudeBot/PerplexityBot) از ۲۴۰ نویسه به ۸٬۵۰۰+ نویسه محتوای معنایی میرسند - Cache-Control: no-store روی HTML و llms.txt (جلوگیری از کش کهنهٔ CDN)، max-age روی sitemap فرانتاند - api.tsx: خواندن دادهٔ تزریقشدهٔ سرور (window.__SITE__) بهعنوان حالت اولیه — رندر بدون XHR - App.tsx: اسکلت بارگذاری بهجای DOM خالی (رفع Soft 404 گوگل) - index.html: فاوآیکون واقعی برند (قبلاً به vite.svg ناموجود اشاره میکرد) + preload فونت زیرساخت - Dockerfile: آینهٔ PyPI (Tsinghua/Aliyun) — PyPI برای IP ایران بلاک است - Caddyfile: هر مسیر پسونددار بدون فایل واقعی ۴۰۴ میگیرد (قبلاً HTML اسپا برمیگرداند) - docker-compose.prod.yml: مسیرهای seo برای صفحات استاتیک + llms.txt + کلیدهای IndexNow داده - seed_data*.py: حذف دفاتر و معرفیهای دمو (که در هر استقرار برمیگشتند)؛ تنها دفتر واقعی: اصفهان، ملاصدرا شمالی داراییها - فاوآیکون/آیکونهای برند از لوگوی رسمی + کلیدهای IndexNow
This commit is contained in:
+5
-1
@@ -7,7 +7,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
# PyPI برای IP ایران بلاک است (connection timeout) — آینهٔ Tsinghua پیشفرض است و
|
||||||
|
# Aliyun بهعنوان منبع دوم؛ با --build-arg قابل بازنویسی است (مثلاً روی CI خارج ایران)
|
||||||
|
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
ARG PIP_EXTRA_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
|
||||||
|
RUN pip install --no-cache-dir -i "$PIP_INDEX_URL" --extra-index-url "$PIP_EXTRA_INDEX_URL" -r requirements.txt
|
||||||
|
|
||||||
# FFmpeg — نرمالسازی رسانه به WebP برای ورودیهایی که Pillow نمیخواند
|
# FFmpeg — نرمالسازی رسانه به WebP برای ورودیهایی که Pillow نمیخواند
|
||||||
# (ویدیو → WebP متحرک، فرمتهای تصویری خاص). مسیر Pillow به آن وابسته نیست.
|
# (ویدیو → WebP متحرک، فرمتهای تصویری خاص). مسیر Pillow به آن وابسته نیست.
|
||||||
|
|||||||
@@ -153,9 +153,11 @@ def get_before_after(db: Session = Depends(get_db)):
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@router.get("/site")
|
def build_site_payload(db: Session) -> dict:
|
||||||
def get_site(db: Session = Depends(get_db)):
|
"""همه محتوای سایت در یک دیکشنری — هم برای GET /api/site و هم برای
|
||||||
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
تزریق مستقیم داخل HTML (window.__SITE__) در app/seo_pages.py.
|
||||||
|
تزریق داخل HTML یعنی فرانتاند بدون انتظار برای XHR رندر میشود —
|
||||||
|
خزندههای رندرکننده (گوگل) هیچوقت صفحهٔ نیمهخالی نمیبینند."""
|
||||||
return {
|
return {
|
||||||
"brand": db.query(models.Brand).first(),
|
"brand": db.query(models.Brand).first(),
|
||||||
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
"nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
|
||||||
@@ -187,3 +189,9 @@ def get_site(db: Session = Depends(get_db)):
|
|||||||
"footer": db.query(models.FooterContent).first(),
|
"footer": db.query(models.FooterContent).first(),
|
||||||
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
|
"seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/site")
|
||||||
|
def get_site(db: Session = Depends(get_db)):
|
||||||
|
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
|
||||||
|
return build_site_payload(db)
|
||||||
|
|||||||
@@ -178,11 +178,8 @@ WHY = [
|
|||||||
{"title": "پایداری و انرژی", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد."},
|
{"title": "پایداری و انرژی", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد."},
|
||||||
]
|
]
|
||||||
|
|
||||||
TESTIMONIALS = [
|
# معرفی مشتریان — خالی. (سه معرفی قبلی افراد ساختگی دمو بودند؛ با دادهٔ واقعی تیم پر میشود.)
|
||||||
{"name": "مهندس سارا کیانی", "role": "سرمایهگذار پروژه پارسه", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید."},
|
TESTIMONIALS = []
|
||||||
{"name": "دکتر امیرحسین راد", "role": "ساکن برج الهیه", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها."},
|
|
||||||
{"name": "مریم دلاور", "role": "مدیر مجموعه ویلاهای دریاکنار", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند."},
|
|
||||||
]
|
|
||||||
|
|
||||||
TEAM = [
|
TEAM = [
|
||||||
{"name": "کامران آرامش", "role": "بنیانگذار و مدیرعامل", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه."},
|
{"name": "کامران آرامش", "role": "بنیانگذار و مدیرعامل", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه."},
|
||||||
@@ -388,10 +385,9 @@ PROCESS_STEPS = [
|
|||||||
{"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی."},
|
{"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی."},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# دفتر مرکزی — تنها دفتر واقعی. (سه دفتر تهران/اصفهان/نوشهرِ قبلی دادهٔ دمو بودند و حذف شدند.)
|
||||||
OFFICES = [
|
OFFICES = [
|
||||||
{"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"},
|
{"city": "اصفهان", "address": "خیابان ملاصدرا شمالی، کوچه ۶، پلاک ۳۶", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
|
||||||
{"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
|
|
||||||
{"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
GALLERY_IMAGES = [
|
GALLERY_IMAGES = [
|
||||||
|
|||||||
@@ -213,11 +213,8 @@ WHY = [
|
|||||||
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
|
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
|
||||||
]
|
]
|
||||||
|
|
||||||
TESTIMONIALS = [
|
# Customer testimonials — empty. (The previous three were fictional demo people.)
|
||||||
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایهگذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارشهای ماهانه گروه ساختمانی آرامش آنقدر دقیق بود که هیچوقت نیاز نشد نگران سرمایهام باشم. پروژه دو ماه زودتر از موعد به بهرهبرداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
|
TESTIMONIALS = []
|
||||||
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده میشود که معمولاً کسی به آنها فکر نمیکند؛ از عایق صوتی پنجرهها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
|
|
||||||
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربهای واقعاً حرفهای بود. ایدههای ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
|
|
||||||
]
|
|
||||||
|
|
||||||
TEAM = [
|
TEAM = [
|
||||||
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیانگذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
|
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیانگذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
|
||||||
@@ -476,10 +473,9 @@ PROCESS_STEPS = [
|
|||||||
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
|
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Head office — the only real office. (The previous Tehran/Isfahan/Nowshahr entries were demo data.)
|
||||||
OFFICES = [
|
OFFICES = [
|
||||||
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
|
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان ملاصدرا شمالی، کوچه ۶، پلاک ۳۶", "address_en": "Mollasadra Shomali St., Alley 6, No. 36, Isfahan", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
|
||||||
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
|
|
||||||
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
GALLERY_IMAGES = [
|
GALLERY_IMAGES = [
|
||||||
|
|||||||
+328
-36
@@ -24,12 +24,15 @@ import re
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
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.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from .api.public import build_site_payload
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
|
|
||||||
@@ -43,6 +46,9 @@ 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)
|
||||||
|
# کلید IndexNow — اثبات مالکیت برای اعلام فوری نشانیهای سایت به Bing/Copilot و
|
||||||
|
# موتورهای مشارکتکننده در پروتکل (فایل در /.well-known/indexnow-key.txt سرو میشود)
|
||||||
|
INDEXNOW_KEY = "daa4488a8e2905312b5efd02ad5fc614"
|
||||||
# خزندههای هوش مصنوعی جستوجو/ارجاع — سیاست صریح «مجاز» تا قالبهای پیشفرض
|
# خزندههای هوش مصنوعی جستوجو/ارجاع — سیاست صریح «مجاز» تا قالبهای پیشفرض
|
||||||
# «بستن همهچیز» اتفاقی آنها را نبندند (GPTBot آموزش است، OAI-SearchBot ارجاع چتجیپیتی)
|
# «بستن همهچیز» اتفاقی آنها را نبندند (GPTBot آموزش است، OAI-SearchBot ارجاع چتجیپیتی)
|
||||||
AI_SEARCH_BOTS = [
|
AI_SEARCH_BOTS = [
|
||||||
@@ -79,6 +85,11 @@ def _fa_path(path: str) -> str:
|
|||||||
return path[3:] if path.startswith("/en/") else path
|
return path[3:] if path.startswith("/en/") else path
|
||||||
|
|
||||||
|
|
||||||
|
def _url_escape(url: str) -> str:
|
||||||
|
"""درصدگذاری نویسههای غیر-ASCII (اسلاگهای فارسی) — استاندارد sitemap و IndexNow."""
|
||||||
|
return quote(url, safe=":/?#[]@!$&'()*+,;=")
|
||||||
|
|
||||||
|
|
||||||
def _clip(text: str, limit: int) -> str:
|
def _clip(text: str, limit: int) -> str:
|
||||||
"""برش متن به طول مشخص برای توضیحهای تکخطی (llms.txt)."""
|
"""برش متن به طول مشخص برای توضیحهای تکخطی (llms.txt)."""
|
||||||
text = " ".join((text or "").split())
|
text = " ".join((text or "").split())
|
||||||
@@ -211,6 +222,16 @@ def _doc_text(doc: object, limit: int = 300) -> str:
|
|||||||
return text[:limit] + ("…" if len(text) > limit else "")
|
return text[:limit] + ("…" if len(text) > limit else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _site_json(db: Session) -> str:
|
||||||
|
"""محتوای کامل سایت بهصورت JSON برای تزریق داخل HTML (window.__SITE__).
|
||||||
|
فرانتاند با همین داده بدون انتظار برای XHR رندر میشود — خزندههای رندرکننده
|
||||||
|
(گوگل) هیچوقت در پنجرهٔ «در حال بارگذاری» صفحهٔ نیمهخالی نمیبینند."""
|
||||||
|
payload = jsonable_encoder(build_site_payload(db))
|
||||||
|
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
# امنسازی درج در HTML: توالی بستن اسکریپت و کامنت HTML نباید در داده ظاهر شود
|
||||||
|
return data.replace("</", "<\\/").replace("<!--", "<\\u0021--")
|
||||||
|
|
||||||
|
|
||||||
def _shell(
|
def _shell(
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -225,6 +246,7 @@ def _shell(
|
|||||||
org_jsonld: dict | None = None,
|
org_jsonld: dict | None = None,
|
||||||
noscript: str = "",
|
noscript: str = "",
|
||||||
status_code: int = 200,
|
status_code: int = 200,
|
||||||
|
db: Session | None = None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""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)
|
||||||
@@ -265,6 +287,9 @@ def _shell(
|
|||||||
if org_jsonld:
|
if org_jsonld:
|
||||||
payload = json.dumps(org_jsonld, ensure_ascii=False).replace("</", "<\\/")
|
payload = json.dumps(org_jsonld, ensure_ascii=False).replace("</", "<\\/")
|
||||||
tags.append(f'<script type="application/ld+json" id="seo-org-jsonld">{payload}</script>')
|
tags.append(f'<script type="application/ld+json" id="seo-org-jsonld">{payload}</script>')
|
||||||
|
if db is not None:
|
||||||
|
# دادهٔ کامل سایت داخل HTML — اولین رندر بدون انتظار برای درخواست /api/site
|
||||||
|
tags.append(f"<script>window.__SITE__={_site_json(db)};</script>")
|
||||||
block = "\n ".join(tags)
|
block = "\n ".join(tags)
|
||||||
|
|
||||||
raw = _read_index()
|
raw = _read_index()
|
||||||
@@ -276,6 +301,7 @@ def _shell(
|
|||||||
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
||||||
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>",
|
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>",
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# اسکیمای Organization مینیمالِ پوستهٔ استاتیک حذف میشود تا با نسخهٔ غنی تزریقی تکراری نشود
|
# اسکیمای Organization مینیمالِ پوستهٔ استاتیک حذف میشود تا با نسخهٔ غنی تزریقی تکراری نشود
|
||||||
@@ -302,7 +328,7 @@ def _shell(
|
|||||||
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||||
if noscript:
|
if noscript:
|
||||||
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||||
return HTMLResponse(raw, status_code=status_code)
|
return HTMLResponse(raw, status_code=status_code, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
# ---------- موجودیتهای ساختیافته (JSON-LD) ----------
|
# ---------- موجودیتهای ساختیافته (JSON-LD) ----------
|
||||||
@@ -355,7 +381,19 @@ def _org_jsonld(db: Session, lang: str) -> dict:
|
|||||||
org["url"] = _absolute("/")
|
org["url"] = _absolute("/")
|
||||||
logo = (brand.logo or "").strip() if brand else ""
|
logo = (brand.logo or "").strip() if brand else ""
|
||||||
if logo:
|
if logo:
|
||||||
org["logo"] = _absolute(logo)
|
# لوگوی رسمی بهصورت ImageObject — تا موتورها و دستیارهای AI آن را
|
||||||
|
# بهعنوان نشان برند بشناسند، نه یک تصویر عمومی بیهویت
|
||||||
|
org["logo"] = {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": _absolute(logo),
|
||||||
|
"contentUrl": _absolute(logo),
|
||||||
|
"name": f"{_brand_name(db, lang)} — {'لوگوی رسمی' if lang == 'fa' else 'official logo'}",
|
||||||
|
"caption": _brand_name(db, lang),
|
||||||
|
}
|
||||||
|
# تصویر شاخص برند بهعنوان تصویر معرفی سازمان
|
||||||
|
hero_img = (brand.hero or "").strip() if brand else ""
|
||||||
|
if hero_img:
|
||||||
|
org["image"] = {"@type": "ImageObject", "url": _absolute(hero_img), "caption": _brand_name(db, lang)}
|
||||||
footer = db.query(models.FooterContent).first()
|
footer = db.query(models.FooterContent).first()
|
||||||
socials = []
|
socials = []
|
||||||
if footer is not None:
|
if footer is not None:
|
||||||
@@ -511,7 +549,9 @@ def robots(db: Session = Depends(get_db)):
|
|||||||
content="User-agent: *\nDisallow: /\n",
|
content="User-agent: *\nDisallow: /\n",
|
||||||
media_type="text/plain; charset=utf-8",
|
media_type="text/plain; charset=utf-8",
|
||||||
)
|
)
|
||||||
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""]
|
# Allow: /api/site — رندرر گوگل موقع رندر صفحه این XHR را هم واکشی میکند؛
|
||||||
|
# Disallow کلی /api باعث میشد «مسدود توسط robots.txt» در منابع صفحه ثبت شود
|
||||||
|
lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", "Allow: /api/site", ""]
|
||||||
# سیاست صریح خزندههای هوش مصنوعی — رباتهای جستوجو/ارجاع آزادند تا محتوا
|
# سیاست صریح خزندههای هوش مصنوعی — رباتهای جستوجو/ارجاع آزادند تا محتوا
|
||||||
# در پاسخهای ChatGPT/Claude/Perplexity/Gemini ارجاع شود
|
# در پاسخهای ChatGPT/Claude/Perplexity/Gemini ارجاع شود
|
||||||
for bot in AI_SEARCH_BOTS:
|
for bot in AI_SEARCH_BOTS:
|
||||||
@@ -540,7 +580,11 @@ def sitemap(db: Session = Depends(get_db)):
|
|||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n'
|
||||||
)
|
)
|
||||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
return Response(
|
||||||
|
content=xml,
|
||||||
|
media_type="application/xml; charset=utf-8",
|
||||||
|
headers={"Cache-Control": "public, max-age=300"},
|
||||||
|
)
|
||||||
esc = html.escape
|
esc = html.escape
|
||||||
|
|
||||||
articles = {
|
articles = {
|
||||||
@@ -557,10 +601,10 @@ def sitemap(db: Session = Depends(get_db)):
|
|||||||
def entry(loc: str, lang: str, has_en: bool) -> str:
|
def entry(loc: str, lang: str, has_en: bool) -> str:
|
||||||
pairs = _alternate_pairs(loc, lang, has_en)
|
pairs = _alternate_pairs(loc, lang, has_en)
|
||||||
links = "\n".join(
|
links = "\n".join(
|
||||||
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(href_)}" />'
|
f' <xhtml:link rel="alternate" hreflang="{code}" href="{esc(_url_escape(href_))}" />'
|
||||||
for code, href_ in pairs
|
for code, href_ in pairs
|
||||||
)
|
)
|
||||||
return f" <url><loc>{esc(_absolute(loc))}</loc>\n{links}\n </url>"
|
return f" <url><loc>{esc(_url_escape(_absolute(loc)))}</loc>\n{links}\n </url>"
|
||||||
|
|
||||||
urls: list[str] = []
|
urls: list[str] = []
|
||||||
# صفحات استاتیک فارسی + معادلهای انگلیسیِ ترجمهشده
|
# صفحات استاتیک فارسی + معادلهای انگلیسیِ ترجمهشده
|
||||||
@@ -589,7 +633,23 @@ def sitemap(db: Session = Depends(get_db)):
|
|||||||
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||||||
f"{chr(10).join(urls)}\n</urlset>\n"
|
f"{chr(10).join(urls)}\n</urlset>\n"
|
||||||
)
|
)
|
||||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
return Response(
|
||||||
|
content=xml,
|
||||||
|
media_type="application/xml; charset=utf-8",
|
||||||
|
headers={"Cache-Control": "public, max-age=300"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/.well-known/indexnow-key.txt", response_class=Response)
|
||||||
|
def indexnow_key_file():
|
||||||
|
"""فایل تأیید مالکیت IndexNow — موتورها با خواندن آن، ارسالهای IndexNow را میپذیرند."""
|
||||||
|
return Response(content=INDEXNOW_KEY, media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"/{INDEXNOW_KEY}.txt", response_class=Response)
|
||||||
|
def indexnow_key_root():
|
||||||
|
"""محل پیشفرض پروتکل IndexNow: /<key>.txt در ریشهٔ دامنه — برخی اعتبارسنجها فقط همین را میخوانند."""
|
||||||
|
return Response(content=INDEXNOW_KEY, media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/llms.txt", response_class=Response)
|
@router.get("/llms.txt", response_class=Response)
|
||||||
@@ -627,12 +687,10 @@ def llms_txt(db: Session = Depends(get_db)):
|
|||||||
("projects", "Projects"), ("blog", "Blog"), ("faq", "FAQ"), ("contact", "Contact"),
|
("projects", "Projects"), ("blog", "Blog"), ("faq", "FAQ"), ("contact", "Contact"),
|
||||||
]:
|
]:
|
||||||
title, description = _meta_for(db, page_key, "en")
|
title, description = _meta_for(db, page_key, "en")
|
||||||
if page_key == "home":
|
url = "/" if page_key == "home" else f"/en/{page_key}"
|
||||||
url = "/"
|
# توضیح متا کوتاه است (۱۵۰–۱۶۰ نویسه) — کامل میآید تا جمله نیمهبریده نشود
|
||||||
else:
|
summary = " ".join((description or "").split())
|
||||||
url = f"/en/{page_key}" if page_key != "projects" else "/en/projects"
|
lines.append(f"- [{label}]({_absolute(url)})" + (f": {summary}" if summary else ""))
|
||||||
summary = _clip(description, 100)
|
|
||||||
lines.append(f"- [{label}]({_absolute(url)}): {summary or label}")
|
|
||||||
featured_projects = (
|
featured_projects = (
|
||||||
db.query(models.Project).order_by(models.Project.position, models.Project.id).limit(6).all()
|
db.query(models.Project).order_by(models.Project.position, models.Project.id).limit(6).all()
|
||||||
)
|
)
|
||||||
@@ -640,8 +698,15 @@ def llms_txt(db: Session = Depends(get_db)):
|
|||||||
lines += ["", "## Featured Projects"]
|
lines += ["", "## Featured Projects"]
|
||||||
for p in featured_projects:
|
for p in featured_projects:
|
||||||
title = (p.title_en or p.title).strip()
|
title = (p.title_en or p.title).strip()
|
||||||
summary = _clip(p.summary_en or p.summary, 100)
|
# خلاصهٔ ثبتشده؛ در نبودش، موقعیت و وضعیت پروژه بهعنوان توضیح مفید
|
||||||
lines.append(f"- [{title}]({_absolute('/en/projects/' + p.slug)}): {summary}")
|
summary = (p.summary_en or p.summary or "").strip()
|
||||||
|
if not summary:
|
||||||
|
place = (p.location_en or p.location or "").strip()
|
||||||
|
summary = f"Project in {place}" if place else ""
|
||||||
|
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 ""))
|
||||||
latest = (
|
latest = (
|
||||||
db.query(models.Article)
|
db.query(models.Article)
|
||||||
.filter(models.Article.status == "published")
|
.filter(models.Article.status == "published")
|
||||||
@@ -653,8 +718,9 @@ def llms_txt(db: Session = Depends(get_db)):
|
|||||||
lines += ["", "## Recent Articles"]
|
lines += ["", "## Recent Articles"]
|
||||||
for a in latest:
|
for a in latest:
|
||||||
title = (a.title_en or a.title).strip()
|
title = (a.title_en or a.title).strip()
|
||||||
summary = _clip(a.excerpt_en or a.excerpt, 100)
|
summary = _clip(a.excerpt_en or a.excerpt, 140)
|
||||||
lines.append(f"- [{title}]({_absolute('/en/blog/' + a.slug)}): {summary}")
|
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 ""))
|
||||||
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():
|
||||||
@@ -664,16 +730,149 @@ def llms_txt(db: Session = Depends(get_db)):
|
|||||||
for o in offices:
|
for o in offices:
|
||||||
city = (o.city_en or o.city).strip()
|
city = (o.city_en or o.city).strip()
|
||||||
street = ((o.address_en or o.address) or o.address).strip()
|
street = ((o.address_en or o.address) or o.address).strip()
|
||||||
if city or street:
|
# اگر نشانی خودش نام شهر را دارد، تکرار نشود («اصفهان، ملاصدرا…» + «، اصفهان»)
|
||||||
contact_lines.append(f"- Office: {_clip(street + ('، ' + city if city and street else city), 120)}")
|
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)}")
|
||||||
if contact_lines:
|
if contact_lines:
|
||||||
lines += ["", "## Contact", *contact_lines]
|
lines += ["", "## Contact", *contact_lines]
|
||||||
return Response(content="\n".join(lines) + "\n", media_type="text/plain; charset=utf-8")
|
# llms.txt زنده از دیتابیس ساخته میشود — بدون کش تا همیشه تازه باشد
|
||||||
|
return Response(
|
||||||
|
content="\n".join(lines) + "\n",
|
||||||
|
media_type="text/plain; charset=utf-8",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# این مسیر باید بعد از همهٔ مسیرهای .txt مشخص (robots/llms/indexnow) تعریف شود
|
||||||
|
# تا هیچکدام را سایه نیندازد
|
||||||
|
@router.get("/{key}.txt", response_class=Response)
|
||||||
|
def indexnow_key_any(key: str):
|
||||||
|
"""هر کلید IndexNow معتبر (۳۲ نویسهٔ هگز) — فایل /{key}.txt همان کلید را برمیگرداند.
|
||||||
|
پنل Bing/دیگر موتورها هر بار کلید تازه میسازند؛ این مسیر یعنی هیچ کلیدی
|
||||||
|
نیاز به افزودن دستی فایل ندارد (فایلهای موجود در بیلد هم معتبر میمانند)."""
|
||||||
|
if re.fullmatch(r"[a-f0-9]{32}", key):
|
||||||
|
return Response(content=key, media_type="text/plain; charset=utf-8")
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
|
||||||
# ---------- صفحات استاتیک فارسی ----------
|
# ---------- صفحات استاتیک فارسی ----------
|
||||||
|
|
||||||
|
|
||||||
|
def _site_content_html(db: Session, page_key: str, lang: str) -> str:
|
||||||
|
"""محتوای واقعی سایت بهصورت متن HTML خوانا — داخل <noscript>.
|
||||||
|
|
||||||
|
خزندههایی که جاوااسکریپت اجرا نمیکنند (اکثر رباتهای هوش مصنوعی: GPTBot،
|
||||||
|
OAI-SearchBot، ClaudeBot، PerplexityBot) فقط HTML اولیه را میخوانند؛ بدون این،
|
||||||
|
از سایت فقط عنوان و یک خط توضیح میدیدند. اینجا معرفی، خدمات، پروژهها، آمار،
|
||||||
|
دفاتر و پرسشوپاسخها به متن واقعی تبدیل میشوند. برای مرورگرهای معمولی نامرئی
|
||||||
|
است (noscript وقتی JS فعال است نمایش داده نمیشود)."""
|
||||||
|
def en(fa_value: str, en_value: str | None) -> str:
|
||||||
|
return (en_value or "").strip() if (lang == "en" and (en_value or "").strip()) else (fa_value or "").strip()
|
||||||
|
|
||||||
|
esc = html.escape
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
brand = _brand_row(db)
|
||||||
|
brand_name = _brand_name(db, lang)
|
||||||
|
hero = db.query(models.Hero).first()
|
||||||
|
if hero:
|
||||||
|
parts.append(f"<h2>{esc(en(hero.title, hero.title_en))}</h2>")
|
||||||
|
sub = en(hero.subtitle, hero.subtitle_en)
|
||||||
|
if sub:
|
||||||
|
parts.append(f"<p>{esc(sub)}</p>")
|
||||||
|
|
||||||
|
if page_key == "home":
|
||||||
|
story = db.query(models.StorySection).first()
|
||||||
|
if story:
|
||||||
|
parts.append(f"<h2>{esc(en(story.title, story.title_en))}</h2>")
|
||||||
|
# نسخهٔ انگلیسی متن داستان اگر ثبت شده باشد، جایگزین میشود
|
||||||
|
paras = story.paragraphs if isinstance(story.paragraphs, list) else []
|
||||||
|
if lang == "en" and isinstance(getattr(story, "paragraphs_en", None), list) and story.paragraphs_en:
|
||||||
|
paras = story.paragraphs_en
|
||||||
|
for para in paras:
|
||||||
|
if isinstance(para, str) and para.strip():
|
||||||
|
parts.append(f"<p>{esc(para.strip())}</p>")
|
||||||
|
|
||||||
|
stats = db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
|
||||||
|
if stats:
|
||||||
|
items = "".join(
|
||||||
|
f"<li>{esc(str(s.value))}{esc(en(s.suffix, s.suffix_en))} — {esc(en(s.label, s.label_en))}</li>"
|
||||||
|
for s in stats
|
||||||
|
)
|
||||||
|
parts.append(f"<h2>{'Key Numbers' if lang == 'en' else 'آمار کلیدی'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
|
if page_key in ("home", "projects"):
|
||||||
|
projects = db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||||
|
if projects:
|
||||||
|
items = ""
|
||||||
|
for p in projects:
|
||||||
|
title = en(p.title, p.title_en)
|
||||||
|
loc = en(p.location, p.location_en)
|
||||||
|
year = en(p.year, p.year_en)
|
||||||
|
meta_bits = "، ".join(x for x in (loc, year) if x)
|
||||||
|
items += f"<li>{esc(title)}" + (f" — {esc(meta_bits)}" if meta_bits else "") + "</li>"
|
||||||
|
parts.append(f"<h2>{'Our Projects' if lang == 'en' else 'پروژههای ما'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
|
if page_key in ("home", "services"):
|
||||||
|
services = db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
|
||||||
|
if services:
|
||||||
|
items = "".join(
|
||||||
|
f"<li><strong>{esc(en(s.title, s.title_en))}</strong>"
|
||||||
|
+ (f": {esc(en(s.desc, s.desc_en))}" if en(s.desc, s.desc_en) else "")
|
||||||
|
+ "</li>"
|
||||||
|
for s in services
|
||||||
|
)
|
||||||
|
parts.append(f"<h2>{'Our Services' if lang == 'en' else 'خدمات ما'}</h2><ul>{items}</ul>")
|
||||||
|
|
||||||
|
if page_key == "investment":
|
||||||
|
models_list = db.query(models.InvestmentModel).order_by(models.InvestmentModel.position).all()
|
||||||
|
for m in models_list:
|
||||||
|
parts.append(f"<h3>{esc(en(m.title, m.title_en))}</h3>")
|
||||||
|
if en(m.desc, m.desc_en):
|
||||||
|
parts.append(f"<p>{esc(en(m.desc, m.desc_en))}</p>")
|
||||||
|
|
||||||
|
if page_key in ("home", "faq"):
|
||||||
|
faqs = db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
|
||||||
|
if faqs:
|
||||||
|
items = "".join(
|
||||||
|
f"<li><strong>{esc(en(f.question, f.question_en))}</strong> {esc(en(f.answer, f.answer_en))}</li>"
|
||||||
|
for f in faqs
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
if team:
|
||||||
|
items = "".join(
|
||||||
|
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"):
|
||||||
|
offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all()
|
||||||
|
contact_bits = []
|
||||||
|
if brand and brand.phone.strip():
|
||||||
|
contact_bits.append(f"<li>{'Phone' if lang == 'en' else 'تلفن'}: {esc(brand.phone.strip())}</li>")
|
||||||
|
if brand and brand.email.strip():
|
||||||
|
contact_bits.append(f"<li>{'Email' if lang == 'en' else 'ایمیل'}: {esc(brand.email.strip())}</li>")
|
||||||
|
if brand and brand.address.strip():
|
||||||
|
label = "Head office" if lang == "en" else "دفتر مرکزی"
|
||||||
|
contact_bits.append(f"<li>{label}: {esc(brand.address.strip())}</li>")
|
||||||
|
for o in offices:
|
||||||
|
city = en(o.city, o.city_en)
|
||||||
|
addr = en(o.address, o.address_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)
|
||||||
|
contact_bits.append(f"<li>{esc(detail)}" + (f" — {esc(hours)}" if hours else "") + "</li>")
|
||||||
|
if contact_bits:
|
||||||
|
parts.append(f"<h2>{'Contact' if lang == 'en' else 'تماس'}</h2><ul>{''.join(contact_bits)}</ul>")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
return "<h1>" + esc(brand_name if page_key == "home" else brand_name) + "</h1>" + "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") -> HTMLResponse:
|
def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") -> HTMLResponse:
|
||||||
"""پوستهٔ صفحات استاتیک — متا از seo_meta، اسکیمای اختصاصی هر صفحه."""
|
"""پوستهٔ صفحات استاتیک — متا از seo_meta، اسکیمای اختصاصی هر صفحه."""
|
||||||
title, description = _meta_for(db, page_key, lang)
|
title, description = _meta_for(db, page_key, lang)
|
||||||
@@ -691,7 +890,14 @@ def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") ->
|
|||||||
business = _localbusiness_jsonld(db, lang, path)
|
business = _localbusiness_jsonld(db, lang, path)
|
||||||
if business:
|
if business:
|
||||||
jsonld.append(business)
|
jsonld.append(business)
|
||||||
|
# noscript با محتوای واقعی — خزندههای بدون جاوااسکریپت (رباتهای هوش مصنوعی)
|
||||||
|
# هم عنوان، توضیح و کل محتوای معنایی صفحه را میبینند
|
||||||
|
noscript = f"<h1>{html.escape(title)}</h1>"
|
||||||
|
if description.strip():
|
||||||
|
noscript += f"<p>{html.escape(description)}</p>"
|
||||||
|
noscript += _site_content_html(db, page_key, lang)
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
path=path,
|
path=path,
|
||||||
@@ -699,6 +905,7 @@ def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") ->
|
|||||||
has_en=_static_has_en(db, page_key),
|
has_en=_static_has_en(db, page_key),
|
||||||
jsonld=jsonld or None,
|
jsonld=jsonld or None,
|
||||||
org_jsonld=_org_jsonld(db, lang),
|
org_jsonld=_org_jsonld(db, lang),
|
||||||
|
noscript=noscript,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -751,6 +958,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
# مقاله ناموجود یا پیشنویس — کد وضعیت ۴۰۴ تا خزنده صفحه را ایندکس نکند؛
|
# مقاله ناموجود یا پیشنویس — کد وضعیت ۴۰۴ تا خزنده صفحه را ایندکس نکند؛
|
||||||
# SPA همان پیام «یافت نشد» را نشان میدهد
|
# SPA همان پیام «یافت نشد» را نشان میدهد
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=_brand_name(db), description="", path=f"/blog/{slug}",
|
title=_brand_name(db), description="", path=f"/blog/{slug}",
|
||||||
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
|
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
|
||||||
)
|
)
|
||||||
@@ -769,6 +977,7 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
jsonld.append(faq_schema)
|
jsonld.append(faq_schema)
|
||||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
description=_meta_desc_for(article, "fa"),
|
description=_meta_desc_for(article, "fa"),
|
||||||
path=f"/blog/{article.slug}",
|
path=f"/blog/{article.slug}",
|
||||||
@@ -782,6 +991,94 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _project_schema(project: models.Project, lang: str, db: Session) -> dict:
|
||||||
|
"""اسکیمای پروژه — همهٔ تصاویر با نام و توضیح به خود پروژه گره میخورند.
|
||||||
|
بدون این، سیستمهای هوش مصنوعی فقط یک فایل تصویر بدون هویت میبینند و
|
||||||
|
نمیتوانند تصویر را به پروژه نسبت دهند."""
|
||||||
|
brand = _brand_name(db, lang)
|
||||||
|
title = (project.title_en or project.title) if lang == "en" else project.title
|
||||||
|
schema: dict = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CreativeWork",
|
||||||
|
"name": title,
|
||||||
|
"inLanguage": lang,
|
||||||
|
"creator": {"@type": "Organization", "name": brand},
|
||||||
|
}
|
||||||
|
location = (project.location_en or project.location) if lang == "en" else project.location
|
||||||
|
summary = (project.summary_en or project.summary) if lang == "en" else project.summary
|
||||||
|
body_text = _doc_text(project.body, 2000) if isinstance(project.body, dict) else ""
|
||||||
|
description = (summary or "").strip() or body_text
|
||||||
|
if description:
|
||||||
|
schema["description"] = description
|
||||||
|
if location:
|
||||||
|
schema["locationCreated"] = {"@type": "Place", "name": location.strip()}
|
||||||
|
# همهٔ تصاویر (شاخص + گالری) بهصورت ImageObject با نام — تا هر تصویر هویت داشته باشد
|
||||||
|
paths = [project.image] if (project.image or "").strip() else []
|
||||||
|
for g in (project.gallery or []):
|
||||||
|
if isinstance(g, str) and g.strip() and g not in paths:
|
||||||
|
paths.append(g)
|
||||||
|
if paths:
|
||||||
|
images = [
|
||||||
|
{
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": _absolute(p),
|
||||||
|
"name": f"{title} — {'تصویر پروژه' if lang == 'fa' else 'project photo'}",
|
||||||
|
"caption": (f"{title}" + (f"، {location.strip()}" if location else "")),
|
||||||
|
"contentUrl": _absolute(p),
|
||||||
|
}
|
||||||
|
for p in paths[:12]
|
||||||
|
]
|
||||||
|
schema["image"] = images if len(images) > 1 else images[0]
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _project_content_html(project: models.Project, lang: str, brand: str) -> str:
|
||||||
|
"""محتوای خوانا صفحهٔ پروژه برای خزندههای بدون جاوااسکریپت + توضیح تصاویر."""
|
||||||
|
def en(fa_value: str, en_value: str | None) -> str:
|
||||||
|
return (en_value or "").strip() if (lang == "en" and (en_value or "").strip()) else (fa_value or "").strip()
|
||||||
|
|
||||||
|
esc = html.escape
|
||||||
|
title = en(project.title, project.title_en)
|
||||||
|
parts = [f"<h1>{esc(title)}</h1>"]
|
||||||
|
if en(project.summary, project.summary_en):
|
||||||
|
parts.append(f"<p>{esc(en(project.summary, project.summary_en))}</p>")
|
||||||
|
elif isinstance(project.body, dict):
|
||||||
|
text = _doc_text(project.body, 2000)
|
||||||
|
if text:
|
||||||
|
parts.append(f"<p>{esc(text)}</p>")
|
||||||
|
|
||||||
|
facts = []
|
||||||
|
for fa_v, en_v, label_fa, label_en in [
|
||||||
|
(project.location, project.location_en, "موقعیت", "Location"),
|
||||||
|
(project.year, project.year_en, "سال", "Year"),
|
||||||
|
(project.area, getattr(project, "area_en", ""), "زیربنا", "Area"),
|
||||||
|
(project.client, getattr(project, "client_en", ""), "کارفرما", "Client"),
|
||||||
|
]:
|
||||||
|
val = en(fa_v or "", en_v)
|
||||||
|
if val:
|
||||||
|
facts.append(f"<li>{label_en if lang == 'en' else label_fa}: {esc(val)}</li>")
|
||||||
|
if facts:
|
||||||
|
parts.append(f"<ul>{''.join(facts)}</ul>")
|
||||||
|
|
||||||
|
# نام و موقعیت هر تصویر — همان چیزی که سیستمهای هوش مصنوعی برای اعتبارسنجی لازم دارند
|
||||||
|
images = []
|
||||||
|
if (project.image or "").strip():
|
||||||
|
images.append(project.image)
|
||||||
|
for g in (project.gallery or []):
|
||||||
|
if isinstance(g, str) and g.strip() and g not in images:
|
||||||
|
images.append(g)
|
||||||
|
if images:
|
||||||
|
label = "Project photos" if lang == "en" else "تصاویر پروژه"
|
||||||
|
items = "".join(
|
||||||
|
f'<li><img src="{esc(p)}" alt="{esc(title)}'
|
||||||
|
+ (f' — {esc(en(project.location, project.location_en))}' if en(project.location, project.location_en) else "")
|
||||||
|
+ f'" /> {esc(title)}</li>'
|
||||||
|
for p in images[:12]
|
||||||
|
)
|
||||||
|
parts.append(f"<h2>{label}</h2><ul>{items}</ul>")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
|
def _faq_jsonld(article: models.Article, lang: str) -> dict | None:
|
||||||
"""اسکیمای FAQPage از پرسشوپاسخهای پایان مقاله."""
|
"""اسکیمای FAQPage از پرسشوپاسخهای پایان مقاله."""
|
||||||
entities = _faq_entities(article.faqs, lang, article.faqs_en)
|
entities = _faq_entities(article.faqs, lang, article.faqs_en)
|
||||||
@@ -803,20 +1100,14 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||||
if project is None:
|
if project is None:
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=_brand_name(db), description="", path=f"/projects/{slug}",
|
title=_brand_name(db), description="", path=f"/projects/{slug}",
|
||||||
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
|
org_jsonld=_org_jsonld(db, "fa"), status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
brand = _brand_name(db)
|
brand = _brand_name(db)
|
||||||
jsonld = [
|
jsonld = [
|
||||||
{
|
_project_schema(project, "fa", db),
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebPage",
|
|
||||||
"name": project.title,
|
|
||||||
"description": project.summary,
|
|
||||||
"image": _absolute(project.image),
|
|
||||||
"inLanguage": "fa",
|
|
||||||
},
|
|
||||||
_breadcrumb_jsonld([
|
_breadcrumb_jsonld([
|
||||||
(_nav_label(db, "/", "fa") or "خانه", "/"),
|
(_nav_label(db, "/", "fa") or "خانه", "/"),
|
||||||
(_nav_label(db, "/projects", "fa") or "پروژهها", "/projects"),
|
(_nav_label(db, "/projects", "fa") or "پروژهها", "/projects"),
|
||||||
@@ -824,6 +1115,7 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=f"{project.title} | {brand}",
|
title=f"{project.title} | {brand}",
|
||||||
description=project.summary,
|
description=project.summary,
|
||||||
path=f"/projects/{project.slug}",
|
path=f"/projects/{project.slug}",
|
||||||
@@ -832,6 +1124,7 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
og_image_alt=project.title,
|
og_image_alt=project.title,
|
||||||
jsonld=jsonld,
|
jsonld=jsonld,
|
||||||
org_jsonld=_org_jsonld(db, "fa"),
|
org_jsonld=_org_jsonld(db, "fa"),
|
||||||
|
noscript=_project_content_html(project, "fa", brand),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -854,6 +1147,7 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
|
|||||||
jsonld.append(faq_schema)
|
jsonld.append(faq_schema)
|
||||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
description=_meta_desc_for(article, "en"),
|
description=_meta_desc_for(article, "en"),
|
||||||
path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}",
|
path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}",
|
||||||
@@ -892,6 +1186,7 @@ def en_blog_detail_route(slug: str, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
if article is None:
|
if article is None:
|
||||||
return _shell(
|
return _shell(
|
||||||
|
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",
|
||||||
org_jsonld=_org_jsonld(db, "en"), status_code=404,
|
org_jsonld=_org_jsonld(db, "en"), status_code=404,
|
||||||
)
|
)
|
||||||
@@ -908,6 +1203,7 @@ def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
project = db.query(models.Project).filter(models.Project.slug == slug).first()
|
||||||
if project is None:
|
if project is None:
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en",
|
title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en",
|
||||||
org_jsonld=_org_jsonld(db, "en"), status_code=404,
|
org_jsonld=_org_jsonld(db, "en"), status_code=404,
|
||||||
)
|
)
|
||||||
@@ -916,14 +1212,7 @@ def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
title = project.title_en or project.title
|
title = project.title_en or project.title
|
||||||
summary = project.summary_en or project.summary
|
summary = project.summary_en or project.summary
|
||||||
jsonld = [
|
jsonld = [
|
||||||
{
|
_project_schema(project, "en", db),
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebPage",
|
|
||||||
"name": title,
|
|
||||||
"description": summary,
|
|
||||||
"image": _absolute(project.image),
|
|
||||||
"inLanguage": "en",
|
|
||||||
},
|
|
||||||
_breadcrumb_jsonld([
|
_breadcrumb_jsonld([
|
||||||
(_nav_label(db, "/", "en") or "Home", "/"),
|
(_nav_label(db, "/", "en") or "Home", "/"),
|
||||||
(_nav_label(db, "/projects", "en") or "Projects", "/en/projects"),
|
(_nav_label(db, "/projects", "en") or "Projects", "/en/projects"),
|
||||||
@@ -931,6 +1220,7 @@ def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=f"{title} | {brand}",
|
title=f"{title} | {brand}",
|
||||||
description=summary,
|
description=summary,
|
||||||
path=f"/en/projects/{project.slug}",
|
path=f"/en/projects/{project.slug}",
|
||||||
@@ -940,6 +1230,7 @@ def en_project_detail(slug: str, db: Session = Depends(get_db)):
|
|||||||
og_image_alt=title,
|
og_image_alt=title,
|
||||||
jsonld=jsonld,
|
jsonld=jsonld,
|
||||||
org_jsonld=_org_jsonld(db, "en"),
|
org_jsonld=_org_jsonld(db, "en"),
|
||||||
|
noscript=_project_content_html(project, "en", brand),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -950,6 +1241,7 @@ def en_page(path: str, db: Session = Depends(get_db)):
|
|||||||
page_key = path.split("/")[0] if path else ""
|
page_key = path.split("/")[0] if path else ""
|
||||||
if page_key not in META_KEYS:
|
if page_key not in META_KEYS:
|
||||||
return _shell(
|
return _shell(
|
||||||
|
db=db,
|
||||||
title=_brand_name(db, "en"), description="", path=f"/en/{path}".rstrip("/"), lang="en",
|
title=_brand_name(db, "en"), description="", path=f"/en/{path}".rstrip("/"), lang="en",
|
||||||
org_jsonld=_org_jsonld(db, "en"), status_code=404,
|
org_jsonld=_org_jsonld(db, "en"), status_code=404,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ services:
|
|||||||
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
- traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers
|
||||||
# سئو: همهٔ صفحات عمومی از بکاند سرو میشوند تا متا در HTML اولیه تزریق شود —
|
# سئو: همهٔ صفحات عمومی از بکاند سرو میشوند تا متا در HTML اولیه تزریق شود —
|
||||||
# مسیرهای استاتیک دقیقاند (Path) تا assets/ فونتها از Caddy سرو شوند
|
# مسیرهای استاتیک دقیقاند (Path) تا assets/ فونتها از Caddy سرو شوند
|
||||||
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`))
|
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`) || Path(`/.well-known/indexnow-key.txt`) || Path(`/daa4488a8e2905312b5efd02ad5fc614.txt`) || PathRegexp(`^/[a-f0-9]{32}\.txt$`))
|
||||||
- traefik.http.routers.seo.entrypoints=websecure
|
- traefik.http.routers.seo.entrypoints=websecure
|
||||||
- traefik.http.routers.seo.tls.certresolver=le
|
- traefik.http.routers.seo.tls.certresolver=le
|
||||||
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
- traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers
|
||||||
|
|||||||
+14
-4
@@ -16,14 +16,24 @@
|
|||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
# مسیریابی SPA — مسیرهای ناشناختهٔ غیرasset به index.html برمیگردند
|
# هر مسیر دارای پسوند فایل (فونت، تصویر، نقشه، آیکون، …): فقط فایل واقعی سرو میشود؛
|
||||||
|
# فایل ناموجود ۴۰۴ میگیرد، نه HTML اسپا — خزندهها (گوگل) پاسخ HTML برای فونت/تصویر
|
||||||
|
# را «خطای منابع» ثبت میکنند و CDN هم همان HTML را کش میکند
|
||||||
|
@filelike path_regexp fileext \.[a-zA-Z0-9]{2,5}$
|
||||||
|
handle @filelike {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# مسیریابی SPA — فقط مسیرهای بدون پسوند (مسیرهای اپ) به index.html برمیگردند
|
||||||
handle {
|
handle {
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|
||||||
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
|
# index.html و fallback اسپا: بدون کش مرورگر تا هر استقرار فوری دیده شود
|
||||||
@notassets not path /assets/*
|
header Cache-Control "no-cache"
|
||||||
header @notassets Cache-Control "no-cache"
|
# فایلهای ثابت (فونت/تصویر): یک ساعت کش مرورگر
|
||||||
|
header @filelike Cache-Control "public, max-age=3600"
|
||||||
|
# داراییهای هشدار: کش یکساله
|
||||||
header @assets Cache-Control "public, immutable, max-age=31536000"
|
header @assets Cache-Control "public, immutable, max-age=31536000"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,11 @@
|
|||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<!-- آیکونهای سایت از لوگوی رسمی برند (قبلاً به vite.svg پیشفرض اشاره میکرد که وجود نداشت) -->
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- پیشبارگذاری فونتهای اصلی — بدون این، swap فونت با تأخیر میآید (LCP) -->
|
<!-- پیشبارگذاری فونتهای اصلی — بدون این، swap فونت با تأخیر میآید (LCP) -->
|
||||||
<link rel="preload" href="/fonts/pinar/Pinar-Light.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="/fonts/pinar/Pinar-Light.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
63730e04d66942f8b422d2d017f67fe4
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
|||||||
|
f48526ce084d4b76a6e522283d3ad68f
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
@@ -149,7 +149,14 @@ function AppShell() {
|
|||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<AppChrome />
|
<AppChrome />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
|
// اسکلت اولیه تا رسیدن دادههای API — صفحه هرگز خالی رندر نمیشود.
|
||||||
|
// (خزندههای رندرکننده — مثل گوگل — صفحهٔ خالی را Soft 404 میبینند)
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-ivory">{t('brandFallback')}</h1>
|
||||||
|
<p className="mt-4 text-sm font-light text-ivory/70">{t('loading')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -228,6 +228,14 @@ interface SiteContextValue {
|
|||||||
updateSite: (mutate: (data: SiteData) => void) => void
|
updateSite: (mutate: (data: SiteData) => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/** دادهٔ سایت که بکاند داخل HTML تزریق میکند (app/seo_pages.py) —
|
||||||
|
* رندر بدون انتظار برای درخواست /api/site؛ خزندههای رندرکننده صفحهٔ کامل میبینند */
|
||||||
|
__SITE__?: SiteData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SiteContext = createContext<SiteContextValue>({
|
const SiteContext = createContext<SiteContextValue>({
|
||||||
data: null,
|
data: null,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -242,7 +250,10 @@ const SiteContext = createContext<SiteContextValue>({
|
|||||||
const SITE_TIMEOUT_MS = 15_000
|
const SITE_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||||
const [data, setData] = useState<SiteData | null>(null)
|
// دادهٔ تزریقشدهٔ سرور (window.__SITE__) بهعنوان حالت اولیه — اولین رندر کامل است
|
||||||
|
// و به درخواست /api/site وابسته نیست؛ درخواست همچنان در پسزمینه تازهسازی میکند
|
||||||
|
const embedded = typeof window !== 'undefined' ? window.__SITE__ : undefined
|
||||||
|
const [data, setData] = useState<SiteData | null>(embedded ?? null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
@@ -284,11 +295,12 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
if (json.brand && json.hero && json.footer && json.nav) setData(json)
|
||||||
else setError(true)
|
// با دادهٔ تزریقشده، پاسخ نامعتبر فقط یعنی «تازهسازی نشد» — خطا نشان نده
|
||||||
|
else if (!embedded) setError(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setError(true)
|
if (!cancelled && !embedded) setError(true)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
|||||||
Reference in New Issue
Block a user