From 61a2e952783b72459b44fcc9de3bccc12b50b483 Mon Sep 17 00:00:00 2001 From: aramesh-site deploy Date: Tue, 6 Oct 2026 10:11:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(blog):=20=D8=AC=D8=AF=D8=A7=D8=B3=D8=A7?= =?UTF-8?q?=D8=B2=DB=8C=20=D8=AF=D9=88=D8=B2=D8=A8=D8=A7=D9=86=D9=87=D9=94?= =?UTF-8?q?=20=D8=A7=D8=B3=D9=84=D8=A7=DA=AF/=DA=A9=D9=84=DB=8C=D8=AF?= =?UTF-8?q?=D9=88=D8=A7=DA=98=D9=87/=D9=86=D9=88=DB=8C=D8=B3=D9=86=D8=AF?= =?UTF-8?q?=D9=87/=D8=AA=DA=AF=E2=80=8C=D9=87=D8=A7/=D9=85=D8=AA=D8=A7=20+?= =?UTF-8?q?=20=D9=85=DB=8C=DA=AF=D8=B1=DB=8C=D8=B4=D9=86=20=DB=B0=DB=B0?= =?UTF-8?q?=DB=B0=DB=B2=D8=8C=20=D8=B1=D9=81=D8=B9=20=C2=AB=D8=AF=D9=82?= =?UTF-8?q?=DB=8C=D9=82=D9=87=20=D9=85=D8=B7=D8=A7=D9=84=D8=B9=D9=87=20?= =?UTF-8?q?=D8=AF=D9=82=DB=8C=D9=82=D9=87=C2=BB=D8=8C=20=D9=81=D9=88=D9=86?= =?UTF-8?q?=D8=AA=20=D9=85=D9=82=D8=A7=D9=84=D9=87=20=DB=B1=DB=B5/=DB=B1?= =?UTF-8?q?=DB=B7px=D8=8C=20=D8=AC=D8=AF=D9=88=D9=84=20=D8=AA=DB=8C=D9=BE?= =?UTF-8?q?=E2=80=8C=D8=AA=D9=BE=20=D8=AF=D8=B1=20=D8=A7=D8=AF=DB=8C=D8=AA?= =?UTF-8?q?=D9=88=D8=B1=D8=8C=20=D9=85=D8=B3=DB=8C=D8=B1=20/en/blog=20?= =?UTF-8?q?=D8=A8=D8=A7=20slug=5Fen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/models.py | 14 + backend/app/schemas.py | 25 + backend/app/seo_pages.py | 799 ++++++++++++++---- .../versions/0002_article_multilang.py | 43 + frontend/package-lock.json | 57 ++ frontend/package.json | 4 + frontend/src/admin/ArticleWritePage.tsx | 37 +- .../src/components/admin/ArticleEditor.tsx | 19 + frontend/src/data/site.ts | 6 +- frontend/src/i18n/localize.ts | 23 +- frontend/src/index.css | 32 +- 11 files changed, 862 insertions(+), 197 deletions(-) create mode 100644 backend/migrations/versions/0002_article_multilang.py diff --git a/backend/app/models.py b/backend/app/models.py index 6a2507e..9035623 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -119,10 +119,24 @@ class Article(Base): faqs: Mapped[list] = mapped_column(JSON, default=list) faqs_en: Mapped[Optional[list]] = mapped_column(JSON) # NULL یعنی بدون ترجمه — برگشت به فارسی author_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # نویسندهٔ creator — برای مقاله‌های ادمین NULL + # نام نویسنده (رشتهٔ آزاد) — سربرگ مقاله و اسکیمای Article (نوع Person برای سیگنال E-E-A-T) + author: Mapped[str] = mapped_column(String(128), default="") # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود status: Mapped[str] = mapped_column(String(16), default="published") # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود seo_keyphrase: Mapped[str] = mapped_column(String(128), default="") + # توضیحات سئوی اختصاصی مقاله — در تگ description و اسکیما، در اولویت بر چکیده + meta_description: Mapped[str] = mapped_column(Text, default="") + meta_description_en: Mapped[str] = mapped_column(Text, default="") + # نشانی انگلیسی مقاله — مسیر /en/blog/...؛ خالی یعنی همان اسلاگ فارسی + slug_en: Mapped[str] = mapped_column(String(128), default="", index=True) + # کلمه کلیدی هدف نسخه انگلیسی — چک‌لیست سئو + seo_keyphrase_en: Mapped[str] = mapped_column(String(128), default="") + # نام نویسنده نسخه انگلیسی + author_en: Mapped[str] = mapped_column(String(128), default="") + # هشتگ‌ها/برچسب‌ها — هر زبان جدا + tags: Mapped[list] = mapped_column(JSON, default=list) + tags_en: Mapped[list] = mapped_column(JSON, default=list) class Testimonial(Base): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b3de70d..143c287 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -98,6 +98,15 @@ class ArticleOut(ORMModel): faqs_en: Optional[list[dict]] = None status: str seo_keyphrase: str + # نام نویسنده و توضیحات سئوی اختصاصی — سربرگ مقاله، تگ description و اسکیمای Article + author: str = "" + meta_description: str = "" + meta_description_en: str = "" + slug_en: str = "" + seo_keyphrase_en: str = "" + author_en: str = "" + tags: list[str] = [] + tags_en: list[str] = [] class TestimonialOut(ORMModel): @@ -517,6 +526,14 @@ class ArticleIn(BaseModel): faqs_en: list[dict] = [] status: Literal["draft", "published"] = "draft" seo_keyphrase: str = Field(default="", max_length=128) + seo_keyphrase_en: str = Field(default="", max_length=128) + slug_en: str = Field(default="", max_length=128) + author: str = Field(default="", max_length=128) + author_en: str = Field(default="", max_length=128) + tags: list[str] = [] + tags_en: list[str] = [] + meta_description: str = "" + meta_description_en: str = "" _validate_body = field_validator("body")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body) @@ -823,6 +840,14 @@ class ArticlePatch(BaseModel): faqs_en: Optional[list[dict]] = None status: Optional[Literal["draft", "published"]] = None seo_keyphrase: Optional[str] = None + seo_keyphrase_en: Optional[str] = None + slug_en: Optional[str] = None + author: Optional[str] = None + author_en: Optional[str] = None + tags: Optional[list[str]] = None + tags_en: Optional[list[str]] = None + meta_description: Optional[str] = None + meta_description_en: Optional[str] = None _validate_body = field_validator("body")(validate_doc_body) _validate_body_en = field_validator("body_en")(validate_doc_body) TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn) diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 0dfab46..977db83 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -1,17 +1,28 @@ -"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریق‌شده. +"""سئوی سمت سرور — robots.txt، sitemap.xml، llms.txt و سرو همهٔ صفحات با متای تزریقی. -فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند. +فرانت‌اند یک SPA است؛ خزنده‌های جست‌وجو و شبکه‌های اجتماعی جاوااسکریپت اجرا نمی‌کنند. این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های -title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند — -بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند. +title / description / canonical / OG / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند — +بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را در HTML اولیه می‌خوانند. +(اسکیمای صفحه داخل ') + # id="seo-jsonld" — سمت مرورگر همان اسکریپت بازنویسی می‌شود، نه اینکه تکراری ساخته شود + tags.append(f'') + if org_jsonld: + payload = json.dumps(org_jsonld, ensure_ascii=False).replace("{payload}') block = "\n ".join(tags) raw = _read_index() @@ -132,14 +274,17 @@ def _shell( body = f"

{esc(title)}

{esc(description)}

" return HTMLResponse( f'' - f"{esc(title)}{alternates}{block}{body}" + f"{esc(title)}{alternates}{block}{body}", + status_code=status_code, ) + # اسکیمای Organization مینیمالِ پوستهٔ استاتیک حذف می‌شود تا با نسخهٔ غنی تزریقی تکراری نشود + if org_jsonld: + raw = re.sub(r'', "", raw, count=1) if lang == "en": # پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود raw = re.sub(r']*>', '', raw, count=1) raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND) - raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"') # description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود raw = re.sub(r']*/>\s*', '', raw, count=1) # og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود @@ -157,11 +302,18 @@ def _shell( raw = raw.replace("", f" {alternates}\n {block}\n ", 1) if noscript: raw = raw.replace("", f"\n ", 1) - return HTMLResponse(raw) + return HTMLResponse(raw, status_code=status_code) + + +# ---------- موجودیت‌های ساخت‌یافته (JSON-LD) ---------- + + +def _brand_row(db: Session) -> models.Brand | None: + return db.query(models.Brand).first() def _brand_name(db: Session, lang: str = "fa") -> str: - brand = db.query(models.Brand).first() + brand = _brand_row(db) if brand is None: return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND if lang == "en": @@ -183,7 +335,172 @@ def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]: return meta.title or "", meta.description or "" -# ---------- robots.txt و sitemap.xml ---------- +def _meta_desc_for(article: models.Article, lang: str) -> str: + """توضیح سئوی مقاله به زبان درخواستی — توضیح اختصاصی در اولویت، چکیده در برگشت.""" + if lang == "en": + return (article.meta_description_en or article.meta_description or article.excerpt_en or article.excerpt or "").strip() + return (article.meta_description or article.excerpt or "").strip() + + +def _org_jsonld(db: Session, lang: str) -> dict: + """اسکیمای Organization — روی همهٔ صفحات تزریق می‌شود (موجودیت برند سایت). + فقط داده‌های واقعی: لوگو، شبکه‌های اجتماعی فوتر و تماس ثبت‌شده در پنل.""" + brand = _brand_row(db) + org: dict = { + "@context": "https://schema.org", + "@type": "Organization", + "name": _brand_name(db, lang), + } + if settings.site_url: + org["url"] = _absolute("/") + logo = (brand.logo or "").strip() if brand else "" + if logo: + org["logo"] = _absolute(logo) + footer = db.query(models.FooterContent).first() + socials = [] + if footer is not None: + socials = (footer.socials_en if lang == "en" and footer.socials_en else footer.socials) or [] + same_as: list[str] = [] + for item in socials if isinstance(socials, list) else []: + if isinstance(item, dict): + href_ = (item.get("href") or "").strip() + if href_.startswith("http") and href_ not in same_as: + same_as.append(href_) + if same_as: + org["sameAs"] = same_as + phone = (brand.phone or "").strip() if brand else "" + if phone: + org["contactPoint"] = [ + {"@type": "ContactPoint", "contactType": "customer service", "telephone": phone} + ] + email = (brand.email or "").strip() if brand else "" + if email: + org["email"] = email + return org + + +def _localbusiness_jsonld(db: Session, lang: str, path: str) -> dict | None: + """اسکیمای GeneralContractor از دفاتر ثبت‌شده — برای صفحه‌های تماس و درباره.""" + offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all() + if not offices: + return None + brand = _brand_row(db) + business: dict = { + "@context": "https://schema.org", + "@type": "GeneralContractor", + "name": _brand_name(db, lang), + "url": _absolute(path), + } + phone = (brand.phone or "").strip() if brand else "" + if phone: + business["telephone"] = phone + email = (brand.email or "").strip() if brand else "" + if email: + business["email"] = email + addresses: list[dict] = [] + areas: list[str] = [] + for office in offices: + city = ((office.city_en or office.city) if lang == "en" else office.city).strip() + street = ((office.address_en or office.address) if lang == "en" else office.address).strip() + address: dict = {"@type": "PostalAddress", "addressCountry": "IR"} + if street: + address["streetAddress"] = street + if city: + address["addressLocality"] = city + areas.append(city) + addresses.append(address) + business["address"] = addresses[0] if len(addresses) == 1 else addresses + if areas: + business["areaServed"] = sorted(set(areas)) + return business + + +def _breadcrumb_jsonld(items: list[tuple[str, str]]) -> dict: + """اسکیمای BreadcrumbList — متناظر با مسیر بطری بصری صفحه.""" + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + {"@type": "ListItem", "position": i + 1, "name": name, "item": _absolute(url)} + for i, (name, url) in enumerate(items) + ], + } + + +def _faq_entities(faqs: object, lang: str = "fa", faqs_en: object = None) -> list[dict]: + """ساخت mainEntity اسکیمای FAQPage از ردیف‌های [{question, answer}].""" + rows = faqs if isinstance(faqs, list) else [] + if lang == "en": + rows_en = faqs_en if isinstance(faqs_en, list) else None + if rows_en: + rows = rows_en + entities = [] + for item in rows: + if isinstance(item, dict): + question = (item.get("question") or "").strip() + answer = (item.get("answer") or "").strip() + if question and answer: + entities.append( + {"@type": "Question", "name": question, + "acceptedAnswer": {"@type": "Answer", "text": answer}} + ) + return entities + + +def _faq_page_jsonld(db: Session, lang: str) -> dict | None: + """اسکیمای FAQPage صفحهٔ پرسش‌وپاسخ — از موجودیت faqs. + (گوگل از اردیبهشت ۱۴۰۵ نتیجهٔ غنی FAQ برای همهٔ سایت‌ها برداشت؛ این اسکیما برای + موتورهای هوش مصنوعی است که پرسش‌وپاسخ ساخت‌یافته را مستقیم می‌خوانند.)""" + rows = db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all() + entities = _faq_entities( + [{"question": f.question, "answer": f.answer} for f in rows], + lang, + [{"question": f.question_en, "answer": f.answer_en} for f in rows], + ) + if not entities: + return None + return {"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": entities} + + +def _article_jsonld(article: models.Article, lang: str, db: Session) -> dict: + """اسکیمای Article — با نویسنده (Person در صورت ثبت نام)، تاریخ انتشار و ناشر.""" + brand = _brand_name(db, lang) + title = (article.title_en or article.title) if lang == "en" else article.title + en_slug = ((article.slug_en or "").strip() or article.slug) if lang == "en" else article.slug + author_name = ((article.author_en or "").strip() or (article.author or "").strip()) if lang == "en" else (article.author or "").strip() + publisher: dict = {"@type": "Organization", "name": brand} + brand_row = _brand_row(db) + logo = (brand_row.logo or "").strip() if brand_row else "" + if logo: + publisher["logo"] = {"@type": "ImageObject", "url": _absolute(logo)} + schema: dict = { + "@context": "https://schema.org", + "@type": "Article", + "headline": title, + "description": _meta_desc_for(article, lang), + "inLanguage": lang, + "author": {"@type": "Person", "name": author_name} if author_name else {"@type": "Organization", "name": brand}, + "publisher": publisher, + "mainEntityOfPage": _absolute(f"/blog/{article.slug}" if lang == "fa" else f"/en/blog/{en_slug}"), + } + image = _absolute(article.image) if (article.image or "").strip() else "" + if image: + schema["image"] = image + published = _parse_site_date(article.date_en if lang == "en" else article.date) or _parse_site_date(article.date) + if published: + schema["datePublished"] = published + return schema + + +def _nav_label(db: Session, href: str, lang: str) -> str: + """برچسب فارسی/انگلیسی یک مسیر از ناوبری — برای پیش‌فرض عنوان و بردکرامب.""" + nav = db.query(models.NavItem).filter(models.NavItem.href == href).first() + if nav is None: + return "" + return ((nav.label_en or nav.label) if lang == "en" else nav.label or "").strip() + + +# ---------- robots.txt و sitemap.xml و llms.txt ---------- @router.get("/robots.txt", response_class=Response) @@ -195,18 +512,23 @@ def robots(db: Session = Depends(get_db)): media_type="text/plain; charset=utf-8", ) lines = ["User-agent: *", "Disallow: /admin", "Disallow: /api", ""] + # سیاست صریح خزنده‌های هوش مصنوعی — ربات‌های جست‌وجو/ارجاع آزادند تا محتوا + # در پاسخ‌های ChatGPT/Claude/Perplexity/Gemini ارجاع شود + for bot in AI_SEARCH_BOTS: + lines += [f"User-agent: {bot}", "Allow: /", ""] + # خزندهٔ تهاجمی با سود کم + lines += ["User-agent: Bytespider", "Disallow: /", ""] if settings.site_url: lines.append(f"Sitemap: {_absolute('/sitemap.xml')}") return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8") -def _sitemap_entry(loc: str, fa: str, en: str) -> str: - esc = html.escape - return ( - f" {esc(_absolute(loc))}\n" - f' \n' - f' \n' - f" " +def _static_has_en(db: Session, page_key: str) -> bool: + """آیا صفحهٔ استاتیک ترجمهٔ انگلیسی واقعی دارد؟ (متای سئوی انگلیسی ثبت شده باشد)""" + meta = _page_meta(db, page_key) + return bool( + meta + and ((meta.title_en or "").strip() or (meta.description_en or "").strip()) ) @@ -219,46 +541,203 @@ def sitemap(db: Session = Depends(get_db)): '\n' ) return Response(content=xml, media_type="application/xml; charset=utf-8") - fa_urls = list(STATIC_PATHS) - fa_urls += [ - f"/blog/{a.slug}" + esc = html.escape + + articles = { + a.slug: a for a in db.query(models.Article) .filter(models.Article.status == "published") .order_by(models.Article.position, models.Article.id) .all() - ] - fa_urls += [ - f"/projects/{p.slug}" - for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() - ] - items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls) + } + projects = { + p.slug: p for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() + } + + def entry(loc: str, lang: str, has_en: bool) -> str: + pairs = _alternate_pairs(loc, lang, has_en) + links = "\n".join( + f' ' + for code, href_ in pairs + ) + return f" {esc(_absolute(loc))}\n{links}\n " + + urls: list[str] = [] + # صفحات استاتیک فارسی + معادل‌های انگلیسیِ ترجمه‌شده + for path in STATIC_PATHS: + page_key = "home" if path == "/" else path.strip("/") + has_en = _static_has_en(db, page_key) + urls.append(entry(path, "fa", has_en)) + if has_en: + urls.append(entry(_en_path(path), "en", True)) + # مقاله‌ها و پروژه‌ها — alternate انگلیسی فقط با وجود ترجمهٔ واقعی + for slug, article in articles.items(): + fa_path = f"/blog/{slug}" + has_en = _article_has_en(article) + urls.append(entry(fa_path, "fa", has_en)) + if has_en: + urls.append(entry(_en_path(fa_path), "en", True)) + for slug, project in projects.items(): + fa_path = f"/projects/{slug}" + has_en = _project_has_en(project) + urls.append(entry(fa_path, "fa", has_en)) + if has_en: + urls.append(entry(_en_path(fa_path), "en", True)) xml = ( '\n' '\n' - f"{items}\n\n" + f"{chr(10).join(urls)}\n\n" ) return Response(content=xml, media_type="application/xml; charset=utf-8") +@router.get("/llms.txt", response_class=Response) +def llms_txt(db: Session = Depends(get_db)): + """راهنمای سایت برای دستیارهای هوش مصنوعی — برند، واقعیت‌های کلیدی و صفحه‌های اصلی. + در حالت پیش‌نمایش خصوصی ۴۰۴ می‌دهد تا ساختار سایت بیرون درز نکند.""" + if settings.preview_mode: + return Response(status_code=404) + brand = _brand_name(db, "en") + _, home_desc = _meta_for(db, "home", "en") + lines = [ + f"# {brand}", + f"> {_clip(home_desc or 'Construction group — engineering, contracting and property investment.', 200)}", + "", + ] + services = [ + (s.title_en or s.title).strip() + for s in db.query(models.Service).order_by(models.Service.position, models.Service.id).all() + if (s.title_en or s.title).strip() + ] + offices = db.query(models.Office).order_by(models.Office.position, models.Office.id).all() + cities = [ + (o.city_en or o.city).strip() for o in offices if (o.city_en or o.city).strip() + ] + facts = [] + if services: + facts.append(f"- Services: {', '.join(services)}") + if cities: + facts.append(f"- Offices: {', '.join(sorted(set(cities)))}") + facts.append("- Languages: Persian at / and English at /en") + lines += ["## Key Facts", *facts, ""] + lines.append("## Pages") + for page_key, label in [ + ("home", "Home"), ("about", "About"), ("services", "Services"), + ("projects", "Projects"), ("blog", "Blog"), ("faq", "FAQ"), ("contact", "Contact"), + ]: + title, description = _meta_for(db, page_key, "en") + if page_key == "home": + url = "/" + else: + url = f"/en/{page_key}" if page_key != "projects" else "/en/projects" + summary = _clip(description, 100) + lines.append(f"- [{label}]({_absolute(url)}): {summary or label}") + featured_projects = ( + db.query(models.Project).order_by(models.Project.position, models.Project.id).limit(6).all() + ) + if featured_projects: + lines += ["", "## Featured Projects"] + for p in featured_projects: + 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}") + latest = ( + db.query(models.Article) + .filter(models.Article.status == "published") + .order_by(models.Article.position, models.Article.id) + .limit(6) + .all() + ) + if latest: + lines += ["", "## Recent Articles"] + for a in latest: + title = (a.title_en or a.title).strip() + summary = _clip(a.excerpt_en or a.excerpt, 100) + lines.append(f"- [{title}]({_absolute('/en/blog/' + a.slug)}): {summary}") + brand_row = _brand_row(db) + contact_lines = [] + if brand_row and (brand_row.phone or "").strip(): + contact_lines.append(f"- Phone: {brand_row.phone.strip()}") + if brand_row and (brand_row.email or "").strip(): + contact_lines.append(f"- Email: {brand_row.email.strip()}") + for o in offices: + city = (o.city_en or o.city).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)}") + if contact_lines: + lines += ["", "## Contact", *contact_lines] + return Response(content="\n".join(lines) + "\n", media_type="text/plain; charset=utf-8") + + +# ---------- صفحات استاتیک فارسی ---------- + + +def _static_shell(db: Session, *, path: str, page_key: str, lang: str = "fa") -> HTMLResponse: + """پوستهٔ صفحات استاتیک — متا از seo_meta، اسکیمای اختصاصی هر صفحه.""" + title, description = _meta_for(db, page_key, lang) + brand = _brand_name(db, lang) + if not title: + # عنوان پیش‌فرض از برچسب ناوبری همان صفحه + label = _nav_label(db, f"/{page_key}", lang) + title = f"{label} | {brand}" if label else brand + jsonld: list[dict] = [] + if page_key == "faq": + faq_schema = _faq_page_jsonld(db, lang) + if faq_schema: + jsonld.append(faq_schema) + elif page_key in ("contact", "about"): + business = _localbusiness_jsonld(db, lang, path) + if business: + jsonld.append(business) + return _shell( + title=title, + description=description, + path=path, + lang=lang, + has_en=_static_has_en(db, page_key), + jsonld=jsonld or None, + org_jsonld=_org_jsonld(db, lang), + ) + + +@router.get("/", response_class=HTMLResponse) +def home(db: Session = Depends(get_db)): + return _static_shell(db, path="/", page_key="home") + + +@router.get("/about", response_class=HTMLResponse) +def about_page(db: Session = Depends(get_db)): + return _static_shell(db, path="/about", page_key="about") + + +@router.get("/services", response_class=HTMLResponse) +def services_page(db: Session = Depends(get_db)): + return _static_shell(db, path="/services", page_key="services") + + +@router.get("/investment", response_class=HTMLResponse) +def investment_page(db: Session = Depends(get_db)): + return _static_shell(db, path="/investment", page_key="investment") + + +@router.get("/faq", response_class=HTMLResponse) +def faq_page(db: Session = Depends(get_db)): + return _static_shell(db, path="/faq", page_key="faq") + + +@router.get("/contact", response_class=HTMLResponse) +def contact_page(db: Session = Depends(get_db)): + return _static_shell(db, path="/contact", page_key="contact") + + # ---------- صفحات وبلاگ ---------- @router.get("/blog", response_class=HTMLResponse) def blog_list(db: Session = Depends(get_db)): - meta = _page_meta(db, "blog") - return _shell( - title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db), - description=meta.description if meta else "", - path="/blog", - ) - - -def _article_pair(article: models.Article, lang: str) -> tuple[str, str]: - """عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی.""" - if lang == "en": - return article.title_en or article.title, article.excerpt_en or article.excerpt - return article.title, article.excerpt + return _static_shell(db, path="/blog", page_key="blog") @router.get("/blog/{slug}", response_class=HTMLResponse) @@ -269,130 +748,122 @@ def blog_detail(slug: str, db: Session = Depends(get_db)): .first() ) if article is None: - # مقاله ناموجود یا پیش‌نویس — پوسته ساده بدون متا؛ SPA پیام «یافت نشد» را نشان می‌دهد - return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}") + # مقاله ناموجود یا پیش‌نویس — کد وضعیت ۴۰۴ تا خزنده صفحه را ایندکس نکند؛ + # SPA همان پیام «یافت نشد» را نشان می‌دهد + return _shell( + title=_brand_name(db), description="", path=f"/blog/{slug}", + org_jsonld=_org_jsonld(db, "fa"), status_code=404, + ) brand = _brand_name(db) - title, excerpt = _article_pair(article, "fa") + title = article.title first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2]) - jsonld: object = { - "@context": "https://schema.org", - "@type": "Article", - "headline": title, - "description": excerpt, - "image": _absolute(article.image), - "inLanguage": "fa", - "author": {"@type": "Organization", "name": brand}, - "publisher": {"@type": "Organization", "name": brand}, - "mainEntityOfPage": _absolute(f"/blog/{article.slug}"), - } - # پرسش‌وپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایه‌ای از دو اسکیما) faq_schema = _faq_jsonld(article, "fa") + breadcrumb = _breadcrumb_jsonld([ + (_nav_label(db, "/", "fa") or "خانه", "/"), + (_nav_label(db, "/blog", "fa") or "وبلاگ", "/blog"), + (title, f"/blog/{article.slug}"), + ]) + jsonld: list[dict] = [_article_jsonld(article, "fa", db), breadcrumb] if faq_schema: - jsonld = [jsonld, faq_schema] + jsonld.append(faq_schema) noscript = f"

{html.escape(title)}

{html.escape(first_text)}

" return _shell( title=f"{title} | {brand}", - description=excerpt, + description=_meta_desc_for(article, "fa"), path=f"/blog/{article.slug}", + has_en=_article_has_en(article), og_image=article.image, + og_image_alt=article.image_alt or title, og_type="article", jsonld=jsonld, + org_jsonld=_org_jsonld(db, "fa"), noscript=noscript, ) +def _faq_jsonld(article: models.Article, lang: str) -> dict | None: + """اسکیمای FAQPage از پرسش‌وپاسخ‌های پایان مقاله.""" + entities = _faq_entities(article.faqs, lang, article.faqs_en) + if not entities: + return None + return {"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": entities} + + # ---------- صفحات پروژه ---------- @router.get("/projects", response_class=HTMLResponse) def projects_list(db: Session = Depends(get_db)): - meta = _page_meta(db, "projects") - return _shell( - title=meta.title if meta and meta.title else "پروژه‌ها | " + _brand_name(db), - description=meta.description if meta else "", - path="/projects", - ) + return _static_shell(db, path="/projects", page_key="projects") @router.get("/projects/{slug}", response_class=HTMLResponse) def project_detail(slug: str, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.slug == slug).first() if project is None: - return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}") + return _shell( + title=_brand_name(db), description="", path=f"/projects/{slug}", + org_jsonld=_org_jsonld(db, "fa"), status_code=404, + ) brand = _brand_name(db) - jsonld = { - "@context": "https://schema.org", - "@type": "WebPage", - "name": project.title, - "description": project.summary, - "image": _absolute(project.image), - "inLanguage": "fa", - } + jsonld = [ + { + "@context": "https://schema.org", + "@type": "WebPage", + "name": project.title, + "description": project.summary, + "image": _absolute(project.image), + "inLanguage": "fa", + }, + _breadcrumb_jsonld([ + (_nav_label(db, "/", "fa") or "خانه", "/"), + (_nav_label(db, "/projects", "fa") or "پروژه‌ها", "/projects"), + (project.title, f"/projects/{project.slug}"), + ]), + ] return _shell( title=f"{project.title} | {brand}", description=project.summary, path=f"/projects/{project.slug}", + has_en=_project_has_en(project), og_image=project.image, + og_image_alt=project.title, jsonld=jsonld, + org_jsonld=_org_jsonld(db, "fa"), ) # ---------- صفحات انگلیسی (/en/...) ---------- -def _faq_jsonld(article: models.Article, lang: str) -> dict | None: - """اسکیمای FAQPage برای نتایج غنی گوگل — از پرسش‌وپاسخ‌های پایان مقاله. - نسخه انگلیسی فقط وقتی ترجمه ثبت شده باشد جایگزین می‌شود.""" - faqs = article.faqs if isinstance(article.faqs, list) else [] - if lang == "en": - faqs_en = article.faqs_en if isinstance(article.faqs_en, list) else None - if faqs_en: - faqs = faqs_en - entities = [ - {"@type": "Question", "name": (f.get("question") or "").strip(), - "acceptedAnswer": {"@type": "Answer", "text": (f.get("answer") or "").strip()}} - for f in faqs - if isinstance(f, dict) and (f.get("question") or "").strip() - ] - if not entities: - return None - return { - "@context": "https://schema.org", - "@type": "FAQPage", - "mainEntity": entities, - } - - def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse: brand = _brand_name(db, "en") - title, excerpt = _article_pair(article, "en") + title = article.title_en or article.title body = article.body_en if isinstance(article.body_en, dict) else article.body first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2]) - jsonld: object = { - "@context": "https://schema.org", - "@type": "Article", - "headline": title, - "description": excerpt, - "image": _absolute(article.image), - "inLanguage": "en", - "author": {"@type": "Organization", "name": brand}, - "publisher": {"@type": "Organization", "name": brand}, - "mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"), - } faq_schema = _faq_jsonld(article, "en") + breadcrumb = _breadcrumb_jsonld([ + (_nav_label(db, "/", "en") or "Home", "/"), + (_nav_label(db, "/blog", "en") or "Blog", "/en/blog"), + (title, f"/en/blog/{(article.slug_en or '').strip() or article.slug}"), + ]) + jsonld: list[dict] = [_article_jsonld(article, "en", db), breadcrumb] if faq_schema: - jsonld = [jsonld, faq_schema] + jsonld.append(faq_schema) noscript = f"

{html.escape(title)}

{html.escape(first_text)}

" return _shell( title=f"{title} | {brand}", - description=excerpt, - path=f"/en/blog/{article.slug}", + description=_meta_desc_for(article, "en"), + path=f"/en/blog/{(article.slug_en or '').strip() or article.slug}", lang="en", + has_en=_article_has_en(article), og_image=article.image, + og_image_alt=article.image_alt or title, og_type="article", jsonld=jsonld, + org_jsonld=_org_jsonld(db, "en"), noscript=noscript, ) @@ -400,80 +871,86 @@ def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse: @router.get("/en", response_class=HTMLResponse) @router.get("/en/", response_class=HTMLResponse) def en_home(db: Session = Depends(get_db)): - title, description = _meta_for(db, "home", "en") - if not title: - title = _brand_name(db, "en") - return _shell(title=title, description=description, path="/en", lang="en") + return _static_shell(db, path="/en", page_key="home", lang="en") @router.get("/en/blog", response_class=HTMLResponse) def en_blog_list(db: Session = Depends(get_db)): - meta = _page_meta(db, "blog") - title = meta.title_en or meta.title if meta else "" - description = meta.description_en or meta.description if meta else "" - if not title: - title = "Blog | " + _brand_name(db, "en") - return _shell(title=title, description=description, path="/en/blog", lang="en") + return _static_shell(db, path="/en/blog", page_key="blog", lang="en") @router.get("/en/blog/{slug}", response_class=HTMLResponse) -def en_blog_detail(slug: str, db: Session = Depends(get_db)): +def en_blog_detail_route(slug: str, db: Session = Depends(get_db)): article = ( db.query(models.Article) - .filter(models.Article.slug == slug, models.Article.status == "published") + .filter( + (models.Article.slug_en == slug) + | ((models.Article.slug_en == "") & (models.Article.slug == slug)), + models.Article.status == "published", + ) .first() ) if article is None: - return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en") + return _shell( + title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en", + org_jsonld=_org_jsonld(db, "en"), status_code=404, + ) return _en_blog_detail(article, db) @router.get("/en/projects", response_class=HTMLResponse) def en_projects_list(db: Session = Depends(get_db)): - meta = _page_meta(db, "projects") - title = meta.title_en or meta.title if meta else "" - description = meta.description_en or meta.description if meta else "" - if not title: - title = "Projects | " + _brand_name(db, "en") - return _shell(title=title, description=description, path="/en/projects", lang="en") + return _static_shell(db, path="/en/projects", page_key="projects", lang="en") @router.get("/en/projects/{slug}", response_class=HTMLResponse) def en_project_detail(slug: str, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.slug == slug).first() if project is None: - return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en") + return _shell( + title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en", + org_jsonld=_org_jsonld(db, "en"), status_code=404, + ) brand = _brand_name(db, "en") title = project.title_en or project.title summary = project.summary_en or project.summary - jsonld = { - "@context": "https://schema.org", - "@type": "WebPage", - "name": title, - "description": summary, - "image": _absolute(project.image), - "inLanguage": "en", - } + jsonld = [ + { + "@context": "https://schema.org", + "@type": "WebPage", + "name": title, + "description": summary, + "image": _absolute(project.image), + "inLanguage": "en", + }, + _breadcrumb_jsonld([ + (_nav_label(db, "/", "en") or "Home", "/"), + (_nav_label(db, "/projects", "en") or "Projects", "/en/projects"), + (title, f"/en/projects/{project.slug}"), + ]), + ] return _shell( title=f"{title} | {brand}", description=summary, path=f"/en/projects/{project.slug}", lang="en", + has_en=_project_has_en(project), og_image=project.image, + og_image_alt=title, jsonld=jsonld, + org_jsonld=_org_jsonld(db, "en"), ) @router.get("/en/{path:path}", response_class=HTMLResponse) def en_page(path: str, db: Session = Depends(get_db)): - """بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی.""" + """بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی. + مسیرهای ناشناخته ۴۰۴ می‌گیرند — پوستهٔ ۲۰۰ با عنوان برند، خطای soft-404 است.""" page_key = path.split("/")[0] if path else "" - title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "") - brand = _brand_name(db, "en") - if not title: - # عنوان پیش‌فرض از برچسب ناوبری انگلیسی همان صفحه - nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None - label = (nav.label_en or nav.label) if nav else "" - title = f"{label} | {brand}" if label else brand - return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en") + if page_key not in META_KEYS: + return _shell( + title=_brand_name(db, "en"), description="", path=f"/en/{path}".rstrip("/"), lang="en", + org_jsonld=_org_jsonld(db, "en"), status_code=404, + ) + return _static_shell(db, path=f"/en/{path}".rstrip("/"), page_key=page_key, lang="en") diff --git a/backend/migrations/versions/0002_article_multilang.py b/backend/migrations/versions/0002_article_multilang.py new file mode 100644 index 0000000..7b74044 --- /dev/null +++ b/backend/migrations/versions/0002_article_multilang.py @@ -0,0 +1,43 @@ +"""ستون‌های چندزبانه مقاله — اسلاگ/کلیدواژه/نویسنده انگلیسی و تگ‌های دو زبانه. + +فیلدهای «نویسنده» و «متا دیسکریپشن» در ادیتور تا پیش از این نسخه یا مشترک +بودند یا بی‌اثر؛ از این نسخه هر زبان ستون خودش را دارد. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-10-01 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002" +down_revision: Union[str, None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + articles = sa.table("articles") + columns = sa.inspect(op.get_bind()).get_columns("articles") + existing = {c["name"] for c in columns} + if "slug_en" not in existing: + op.add_column("articles", sa.Column("slug_en", sa.String(128), nullable=False, server_default="")) + if "seo_keyphrase_en" not in existing: + op.add_column("articles", sa.Column("seo_keyphrase_en", sa.String(128), nullable=False, server_default="")) + if "author_en" not in existing: + op.add_column("articles", sa.Column("author_en", sa.String(128), nullable=False, server_default="")) + if "tags" not in existing: + op.add_column("articles", sa.Column("tags", sa.JSON(), nullable=False, server_default="[]")) + if "tags_en" not in existing: + op.add_column("articles", sa.Column("tags_en", sa.JSON(), nullable=False, server_default="[]")) + _ = articles + + +def downgrade() -> None: + op.drop_column("articles", "tags_en") + op.drop_column("articles", "tags") + op.drop_column("articles", "author_en") + op.drop_column("articles", "seo_keyphrase_en") + op.drop_column("articles", "slug_en") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ff7318..7f9e1f1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,10 @@ "@emotion/react": "^11.13.3", "@tiptap/extension-image": "^3.31.3", "@tiptap/extension-link": "^3.31.3", + "@tiptap/extension-table": "^3.31.3", + "@tiptap/extension-table-cell": "^3.31.3", + "@tiptap/extension-table-header": "^3.31.3", + "@tiptap/extension-table-row": "^3.31.3", "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", @@ -1866,6 +1870,59 @@ "@tiptap/core": "3.31.3" } }, + "node_modules/@tiptap/extension-table": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz", + "integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.31.3.tgz", + "integrity": "sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.31.3" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.31.3.tgz", + "integrity": "sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.31.3" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.31.3.tgz", + "integrity": "sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.31.3" + } + }, "node_modules/@tiptap/extension-text": { "version": "3.31.3", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index d8726e0..9a2badb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,10 @@ "@emotion/react": "^11.13.3", "@tiptap/extension-image": "^3.31.3", "@tiptap/extension-link": "^3.31.3", + "@tiptap/extension-table": "^3.31.3", + "@tiptap/extension-table-cell": "^3.31.3", + "@tiptap/extension-table-header": "^3.31.3", + "@tiptap/extension-table-row": "^3.31.3", "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", "@tiptap/starter-kit": "^3.31.3", diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index b095366..f02cecd 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -14,7 +14,7 @@ import { CategorySelect, LuxeTagsSelect } from '../components/admin/LuxeSelect' import type { CategoryOption } from '../components/admin/LuxeSelect' import { ARTICLE_CATEGORIES } from './entities' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' -import { formatReadingTime } from '../utils/readingTime' +import { formatReadingTime, readingTimeMinutes } from '../utils/readingTime' import { toArticleBodyDoc } from '../utils/tiptapDoc' import type { Article, ArticleBodyDoc } from '../data/site' @@ -341,6 +341,9 @@ export function ArticleWritePage() { const metaLength = (draft.meta_description ?? '').trim().length // در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند const suf = (name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft + // مقدار فعال فیلد دوزبانه — برای value ورودی‌ها و چک‌لیست سئو + const activeVal = (name: 'title' | 'slug' | 'author' | 'seo_keyphrase' | 'meta_description' | 'excerpt'): string => + String((editLang === 'en' ? draft[`${name}_en` as keyof Draft] : draft[name]) ?? '') // متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX] @@ -410,7 +413,7 @@ export function ArticleWritePage() { )} @@ -496,11 +499,12 @@ export function ArticleWritePage() { doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record} onChange={(json) => { // زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری - const minutes = formatReadingTime(json) + const minutes = readingTimeMinutes(json) if (editLang === 'en') { queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` }) } else { - queueSave({ body: json, read: `${minutes} دقیقه` }) + // formatReadingTime خودش برچسب «مطالعه» را دارد — پسوند اضافه ممنوع + queueSave({ body: json, read: formatReadingTime(json) }) } }} /> @@ -524,8 +528,8 @@ export function ArticleWritePage() {