وبلاگ: نام‌گذاری و متادیتای تصاویر، پرسش‌وپاسخ مقاله، میزبانی تصاویر چسبانده‌شده

- Alt/Title تصویر شاخص اکنون ذخیره می‌شود (ستون‌های جدید image_alt/image_title — قبلاً بی‌صدا دور ریخته می‌شد)
- Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایش‌اند (کنار کپشن)
- نام فایل ذخیره‌سازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (به‌جای UUID خالی)
- پنل پرسش‌وپاسخ پایان مقاله (ستون‌های faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage
- چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی می‌شوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
This commit is contained in:
MMD
2026-09-26 22:46:56 +03:30
parent 851e3fc2dc
commit 1d29e50852
15 changed files with 580 additions and 45 deletions
+39 -11
View File
@@ -6,6 +6,7 @@ import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button';
import { ArticleCard } from '../components/ArticleCard';
import { ArticleMeta } from '../components/ArticleMeta';
import { ArticleFaq } from '../components/ArticleFaq';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api';
@@ -47,6 +48,35 @@ export function ArticleDetail() {
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
useEffect(() => {
if (!article) return;
let jsonLd: Record<string, unknown> | Record<string, unknown>[] = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.meta_description || article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: lang,
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
publisher: { '@type': 'Organization', name: brand.name },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
};
// پرسش‌وپاسخ مقاله → اسکیمای FAQPage در همان بلوک (آرایه‌ای از دو اسکیما)
const articleFaqs = article.faqs ?? [];
if (articleFaqs.length) {
jsonLd = [
jsonLd,
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: articleFaqs
.filter((f) => f.question.trim())
.map((f) => ({
'@type': 'Question',
name: f.question,
acceptedAnswer: { '@type': 'Answer', text: f.answer },
})),
},
];
}
applySeo({
title: `${article.title} | ${brand.name}`,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
@@ -55,17 +85,7 @@ export function ArticleDetail() {
image: article.image,
type: 'article',
lang,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.meta_description || article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: lang,
author: { '@type': 'Organization', name: article.author?.trim() || brand.name },
publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href,
},
jsonLd,
});
}, [article?.id, lang, href]);
@@ -171,6 +191,14 @@ export function ArticleDetail() {
</div>
</article>
{/* پرسش‌وپاسخ‌های پایان مقاله — همان ظاهر آکاردئونی FAQ سایت */}
<div className="bg-ink pb-4">
<div className="mx-auto max-w-5xl px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('fallbackFaqTitle')}</h2>
<ArticleFaq faqs={article.faqs ?? []} />
</div>
</div>
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}>
<div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2>