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:
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user