fix(blog): کلید زبان در صفحهٔ مقاله ۴۰۴ می‌داد + اسلاگ‌های انگلیسی نامعتبر

باگ کاربر: در پیش‌نمایش فارسی مقاله، دکمهٔ EN به '/en' + اسلاگ فارسی می‌رفت
(«مقاله‌ای وجود ندارد») چون اسلاگ دو زبان فرق دارد.
- LangToggle: نگاشت آگاه از اسلاگ مقاله (fa↔en از روی slug_en) — هم статье
  و هم فهرست وبلاگ
- seo.ts: مسیر alternate صریح (altFa/altEn)؛ پیش از این localeHref پیشوند ساده
  می‌ساخت و hreflang درست سرور را بعد از هیدریشن خراب می‌کرد
- backend: /en/blog/{fa-slug} → 301 به اسلاگ انگلیسی و /blog/{en-slug} → 301
  به نسخهٔ انگلیسی (جلوگیری از ۴۰۴ برای نشانی‌های اشتباه)
- داده: ۱۰ مقدار slug_en که «عنوان با فاصله» بودند به اسلاگ واقعی تبدیل شدند
  (مثل 'Principles of Commercial Building Design' → principles-of-commercial-...)؛
  URL با فاصله هرگز کار نمی‌کرد
This commit is contained in:
aramesh-site deploy
2026-10-25 19:51:50 +00:00
parent a0a8985984
commit aa13d41b6d
4 changed files with 182 additions and 36 deletions
+19 -1
View File
@@ -1,13 +1,31 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
import { useSite } from '../api'
/** مسیر مقابل صفحهٔ مقاله — اسلاگ فارسی/انگلیسی مقاله فرق دارند و پیشوند
* سادهٔ /en نشانی غلط (۴۰۴) می‌سازد؛ همین‌جا نگاشت درست انجام می‌شود */
function articleCounterpart(pathname: string, articles: { slug: string; slug_en?: string }[]): string | null {
const fa = pathname.match(/^\/blog\/([^/]+)$/)
const en = pathname.match(/^\/en\/blog\/([^/]+)$/)
if (!fa && !en) return null
const current = decodeURIComponent((fa ?? en)![1])
const article = articles.find((a) => a.slug === current || a.slug_en === current)
if (!article) return null
if (fa) {
const enSlug = article.slug_en?.trim()
return enSlug ? `/en/blog/${encodeURIComponent(enSlug)}` : pathname === '/blog' ? '/en/blog' : `/en${pathname}`
}
return `/blog/${encodeURIComponent(article.slug)}`
}
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang()
const navigate = useNavigate()
const { pathname } = useLocation()
const target = counterpartPath(pathname, lang)
const { articles } = useSite()
const target = articleCounterpart(pathname, articles) ?? counterpartPath(pathname, lang)
const switchLang = () => {
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
+22 -3
View File
@@ -11,6 +11,7 @@ import { TableOfContents, extractToc, type TocItem } from '../components/TableOf
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { useAdmin } from '../admin/AdminContext';
import { applySeo } from '../seo';
import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
@@ -44,12 +45,16 @@ export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>();
const { articles, brand } = useSite();
const { t, href, lang } = useLang();
const article = articles.find((a) => a.slug === slug);
const { isAuthenticated } = useAdmin();
const article = articles.find((a) => a.slug === slug || (!!a.slug_en && a.slug_en === slug));
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — هم‌ارز نسخهٔ سمت سرور (seo_pages.py)
useEffect(() => {
if (!article) return;
const pageUrl = new URL(href(`/blog/${article.slug}`), window.location.origin).href;
// نشانی خود مقاله — در انگلیسی همان اسلاگ انگلیسی است تا canonical با آدرس
// واقعی مرورگر یکی بماند (نویسنده از «دیدن در سایت» با همان اسلاگ می‌آید)
const pathSlug = lang === 'en' && article.slug_en ? article.slug_en : article.slug;
const pageUrl = new URL(href(`/blog/${pathSlug}`), window.location.origin).href;
const absolute = (path: string) => new URL(path, window.location.origin).href;
const authorName = (article.author ?? '').trim();
const description = article.meta_description || article.excerpt;
@@ -93,12 +98,15 @@ export function ArticleDetail() {
title: `${article.title} | ${brand.name}`,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description,
path: href(`/blog/${article.slug}`),
path: href(`/blog/${encodeURIComponent(pathSlug)}`),
image: article.image,
type: 'article',
lang,
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)),
// اسلاگ دو زبان فرق دارد — مسیرهای صریح تا hreflang بعد از هیدریشن درست بماند
altFa: `/blog/${encodeURIComponent(article.slug)}`,
altEn: article.slug_en?.trim() ? `/en/blog/${encodeURIComponent(article.slug_en.trim())}` : undefined,
jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])],
});
}, [article?.id, lang, href]);
@@ -163,6 +171,17 @@ export function ArticleDetail() {
{/* نویسنده، تاریخ جلالی و زمان مطالعه */}
<ArticleMeta article={article} className="mt-7" />
{/* میان‌بر ویرایش پیش‌نویس برای کارمند واردشده — تا از خود صفحه هم
بدون برگشت به فهرست، ویرایشگر باز شود */}
{isAuthenticated && article.status === 'draft' && (
<Link
to={`/admin/write/${article.id}`}
className="mt-5 inline-block border border-copper/60 bg-ink/90 px-3 py-1.5 text-xs font-bold text-copper backdrop-blur transition-colors duration-200 hover:bg-copper hover:text-char"
>
{dir === 'ltr' ? 'Edit draft' : 'ویرایش پیش‌نویس'}
</Link>
)}
</div>
</section>
+12 -4
View File
@@ -64,7 +64,7 @@ function removeAlternate(hreflang: string): void {
/** لینک‌های alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر.
* مجموعهٔ کامل (fa/en/x-default) فقط وقتی اعلام می‌شود که ترجمهٔ انگلیسی واقعاً
* وجود داشته باشد؛ مجموعهٔ ناسازگار کل hreflang را بی‌اعتبار می‌کند. */
function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void {
function upsertAlternates(path: string, lang: Lang, hasEn: boolean, altFa?: string, altEn?: string): void {
// صفحهٔ انگلیسیِ ترجمه‌نشده هیچ ادعایی ندارد؛ صفحهٔ فارسی فقط خودش و x-default
const targets: [Lang, string][] = hasEn
? [['fa', 'fa'], ['en', 'en'], ['fa', 'x-default']]
@@ -73,8 +73,9 @@ function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void {
: []
if (!hasEn) removeAlternate('en')
for (const [targetLang, hreflang] of targets) {
// x-default همیشه نسخه فارسی است
const target = localeHref(path, targetLang)
// x-default همیشه نسخه فارسی است؛ مسیر صریح مقاله بر پیشوند ساده ترجیح دارد
const explicit = hreflang === 'fa' || hreflang === 'x-default' ? altFa : hreflang === 'en' ? altEn : undefined
const target = explicit ?? localeHref(path, targetLang)
const attr = `data-hreflang="${hreflang}"`
let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`)
if (!el) {
@@ -118,10 +119,17 @@ export type SeoInput = {
hasEn?: boolean
/** اسکیمای JSON-LD — یک شیء یا آرایه‌ای از چند اسکیما (مثل Article + Breadcrumb + FAQPage) */
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
/** مسیر alternate فارسی/انگلیسی صریح — برای مقالات که اسلاگ دو زبانشان فرق دارد */
altFa?: string
altEn?: string
}
/** تنظیم همه تگ‌های سئوی صفحه در یک فراخوانی */
export function applySeo(input: SeoInput): void {
// alternateهای صریح (مقالات: اسلاگ fa/en فرق دارد) — بدون این، localeHref
// نشانی غلط می‌سازد و hreflang درست سرور را بعد از هیدریشن خراب می‌کند
const altFa = input.altFa?.trim()
const altEn = input.altEn?.trim()
const title = input.title.trim()
const description = (input.description ?? '').trim()
const path = input.path ?? window.location.pathname
@@ -156,6 +164,6 @@ export function applySeo(input: SeoInput): void {
upsertMeta('property', 'og:image:height', '630')
}
}
upsertAlternates(path, lang, hasEn)
upsertAlternates(path, lang, hasEn, altFa, altEn)
setJsonLd(input.jsonLd ?? null)
}