feat(stack): full overhaul — hardening, ops tooling, monitoring, analytics, chatbot

Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live;
rate-limited public forms with honeypots; real client IP behind Traefik
(--proxy-headers); TipTap URL sanitization on write (nh3); non-root
containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1;
HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code.

Reliability: Alembic migrations (create_all baseline); graceful shutdown
(exec uvicorn); hardened backups (checksums, sanity check, freshness gauge,
optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops
profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring);
.gitattributes LF for shell scripts.

Orchestration: json-file log rotation, pinned images, per-service memory
limits, edge/internal networks, Traefik ping/accesslog/metrics + flood
limiter, conditional minio-setup, pgvector db image, one-shot db-init
(umami + aramesh_chatbot databases).

Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget).

Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor,
node/postgres/blackbox exporters, provisioned dashboards and 10 alert
rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible).

Analytics: Umami on shared Postgres, build-time tracker injection, custom
events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts.

Hygiene: CSV export for contact messages and subscribers; upload image
optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh;
gen-env.sh for strong credentials.
This commit is contained in:
MMD
2026-09-19 00:24:57 +03:30
parent 805f4594d4
commit ce0735d775
75 changed files with 4287 additions and 153 deletions
+4
View File
@@ -0,0 +1,4 @@
# فقط Caddyfile و dist/ به کانتکست بیلد می‌روند — node_modules و سورس عظیم‌اند و لازم نیستند
*
!Caddyfile
!dist
+11 -2
View File
@@ -1,12 +1,21 @@
{
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
admin off
}
:80 {
root * /srv
encode gzip
encode zstd gzip
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند
try_files {path} /index.html
file_server
# کش بلندمدت برای فایل‌های هش‌دار بیلد
# فایل‌های هش‌دار بیلد: کش بلندمدت بی‌رحم
@assets path /assets/*
header @assets Cache-Control "public, immutable, max-age=31536000"
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
@notassets not path /assets/*
header @notassets Cache-Control "no-cache"
}
+4 -1
View File
@@ -5,6 +5,9 @@
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY dist /srv
COPY --chown=caddy:caddy dist /srv
# بدون root — فقط فایل‌های استاتیک سرو می‌شوند و TLS را Traefik تمام می‌کند
USER caddy
EXPOSE 80
+10
View File
@@ -24,6 +24,16 @@
document.documentElement.setAttribute('dir', 'ltr');
}
</script>
<script type="module">
// تحلیل رفتار (Umami) — در زمان بیلد تزریق می‌شود؛ VITE_UMAMI_SRC خالی یعنی خاموش
if (import.meta.env.VITE_UMAMI_SRC) {
const s = document.createElement('script');
s.defer = true;
s.src = import.meta.env.VITE_UMAMI_SRC;
s.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_WEBSITE_ID ?? '');
document.head.appendChild(s);
}
</script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+2
View File
@@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { FloatingContact } from './components/FloatingContact';
import { ChatWidget } from './components/ChatWidget';
import { Home } from './pages/Home';
import { About } from './pages/About';
import { ServicesPage } from './pages/ServicesPage';
@@ -106,6 +107,7 @@ function AppChrome() {
<AnimatedRoutes />
{!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />}
{!isAdminPage && <ChatWidget />}
{isAuthenticated && !isAdminPage && <AdminBar />}
</>
);
+22
View File
@@ -188,6 +188,28 @@ export function AdminBar() {
رمز
</button>
<button
type="button"
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی پیام‌ها', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
})}
title="دانلود پیام‌های فرم تماس به‌صورت CSV"
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
پیام‌ها (CSV)
</button>
<button
type="button"
onClick={() => void adminApi.downloadCsv('subscribers').catch((e) => {
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی خبرنامه', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
})}
title="دانلود مشترکان خبرنامه به‌صورت CSV"
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
خبرنامه (CSV)
</button>
<button
type="button"
onClick={logout}
+20
View File
@@ -94,4 +94,24 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
// خروجی CSV — فایل تولیدی بک‌اند (با BOM برای Excel) مستقیم دانلود می‌شود
downloadCsv: async (kind: 'messages' | 'subscribers'): Promise<void> => {
const path = kind === 'messages' ? 'messages/export.csv' : 'subscribers/export.csv'
const token = getToken()
const res = await fetch(`${API_URL}/api/admin/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new HttpError(res.status, 'دریافت خروجی CSV ناموفق بود')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const disposition = res.headers.get('Content-Disposition') ?? ''
a.download = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `${kind}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
},
}
+17
View File
@@ -0,0 +1,17 @@
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانی‌ها بی‌اثر و ارزان‌اند.
declare global {
interface Window {
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
}
}
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده می‌شود. */
export function track(event: string, data?: Record<string, unknown>) {
try {
window.umami?.track(event, data)
} catch {
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
}
}
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
import { track } from '../analytics';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
streaming?: boolean;
error?: boolean;
};
type SseFrame = {
content?: string;
error?: string;
done?: boolean;
};
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
function getThreadId(): string {
const KEY = 'chat_thread_id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
sessionStorage.setItem(KEY, id);
}
return id;
}
/** خواندن فریم‌های SSE از پاسخ fetch (استریم توکن‌به‌توکن) */
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
const reader = response.body?.getReader();
if (!reader) throw new Error('پاسخ استریمی ندارد');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
try {
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
} catch {
// فریم ناقص — نادیده بگیر
}
}
}
}
}
export function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const assistantIndexRef = useRef<number | null>(null);
// پیشنهاد پرسش‌ها فقط یک بار برای هر بازشدن اول
useEffect(() => {
if (!open || suggestions.length > 0) return;
fetch('/chat/suggestions')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, [open, suggestions.length]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, open]);
const sendMessage = async (text: string) => {
const question = text.trim();
if (!question || isLoading) return;
track('chat-question')
setInput('');
setIsLoading(true);
setMessages((prev) => {
const next: ChatMessage[] = [
...prev,
{ role: 'user', content: question },
{ role: 'assistant', content: '', streaming: true },
];
assistantIndexRef.current = next.length - 1;
return next;
});
const patchAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const idx = assistantIndexRef.current;
if (idx === null || !prev[idx]) return prev;
const updated = [...prev];
updated[idx] = { ...updated[idx], ...patch };
return updated;
});
};
try {
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
const response = await fetch(`/chat/stream?${params.toString()}`, {
headers: { Accept: 'text/event-stream' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let accumulated = '';
await readSse(response, (frame) => {
if (frame.error) {
patchAssistant({ content: frame.error, streaming: false, error: true });
return;
}
if (frame.content) {
accumulated += frame.content;
patchAssistant({ content: accumulated });
}
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
});
patchAssistant({ streaming: false });
} catch (err) {
patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false,
error: true,
});
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
return (
<>
{/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */}
<button
type="button"
onClick={() => {
setOpen((v) => {
if (!v) track('chat-open');
return !v;
});
}}
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
</button>
{/* پنل گفتگو */}
{open && (
<div
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
{/* سربرگ */}
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
<SparklesIcon className="h-4 w-4" />
</span>
<div className="flex-1">
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* پیام‌ها */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="mt-6 text-center">
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
دارید بپرسید.
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
msg.role === 'user'
? 'rounded-bl-md bg-copper text-char'
: msg.error
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
}`}>
{msg.content || (msg.streaming ? '…' : '')}
{msg.streaming && msg.content && (
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
)}
</div>
</div>
))}
<div ref={scrollEndRef} />
</div>
{/* پیشنهاد پرسش‌ها */}
{suggestions.length > 0 && messages.length === 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
{suggestions.map((q) => (
<button
key={q}
type="button"
onClick={() => sendMessage(q)}
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
{q}
</button>
))}
</div>
)}
{/* ورودی */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
}}
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="پرسش خود را بنویسید…"
disabled={isLoading}
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
aria-label="ارسال"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
<SendIcon className="h-4 w-4 -scale-x-100" />
</button>
</form>
</div>
)}
</>
);
}
+16 -2
View File
@@ -9,12 +9,13 @@ import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable'
import { API_URL } from '../config'
import { track } from '../analytics'
export function ContactSection() {
const { brand, contact_section: content } = useSite()
const { t } = useLang()
const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
@@ -79,7 +80,7 @@ export function ContactSection() {
const res = await fetch(`${API_URL}/api/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }),
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim(), website: form.website }),
})
if (!res.ok) {
@@ -90,6 +91,7 @@ export function ContactSection() {
return
}
track('contact-submit')
setSent(true)
} catch {
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
@@ -152,6 +154,7 @@ export function ContactSection() {
<a
href={brand.phone_href}
dir="ltr"
onClick={() => track('phone-click', { from: 'contact-section' })}
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
>
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
@@ -208,6 +211,17 @@ export function ContactSection() {
</div>
) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
{/* honeypot — خارج از دید؛ فقط ربات‌ها آن را پر می‌کنند */}
<input
type="text"
name="website"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div>
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
@@ -2,6 +2,7 @@ import React from 'react';
import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { track } from '../analytics';
export function FloatingContact() {
const { brand } = useSite();
@@ -11,6 +12,7 @@ export function FloatingContact() {
<a
href={brand.phone_href}
aria-label={t('callBrand')}
onClick={() => track('phone-click', { from: 'floating' })}
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
>
{/* پالس هاله نوری در پس‌زمینه مانند رادار */}
+4 -1
View File
@@ -1,6 +1,7 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
import { track } from '../analytics'
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
@@ -10,7 +11,9 @@ export function LangToggle({ className = '' }: { className?: string }) {
const target = counterpartPath(pathname, lang)
const switchLang = () => {
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
const next = lang === 'fa' ? 'en' : 'fa'
track('lang-switch', { to: next })
writeLangCookie(next)
// پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد
sessionStorage.setItem('langAutoRedirect', '1')
navigate(target)
+1 -1
View File
@@ -43,7 +43,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
>
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question}
+15 -1
View File
@@ -9,12 +9,14 @@ import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable'
import { API_URL } from '../../config'
import { track } from '../../analytics'
export function Newsletter() {
const { newsletter } = useSite()
const { t } = useLang()
const save = useFieldSaver()
const [email, setEmail] = useState('')
const [website, setWebsite] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false)
@@ -75,7 +77,7 @@ export function Newsletter() {
const res = await fetch(`${API_URL}/api/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue }),
body: JSON.stringify({ email: emailValue, website }),
})
if (!res.ok) {
@@ -87,6 +89,7 @@ export function Newsletter() {
return
}
track('newsletter-signup')
setStatus('done')
setEmail('')
} catch {
@@ -117,6 +120,17 @@ export function Newsletter() {
<Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
{/* honeypot — خارج از دید؛ فقط ربات‌ها آن را پر می‌کنند */}
<input
type="text"
name="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only">
{newsletter.placeholder}
+1 -1
View File
@@ -20,7 +20,7 @@ export function Services() {
return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
<div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right">
<Reveal className="max-w-2xl text-start">
<h2 id="services-title" className="text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
+27 -5
View File
@@ -130,21 +130,42 @@ html[lang='en'] .article-body h3 {
}
.article-body h2 {
font-size: 16px;
padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c;
/* نوار مسی ندارد: نوار عمودی مسی نشانه اختصاصی نقل‌قول است و تیتر فقط با
اندازه و وزن متمایز می‌شود تا با نقل‌قول اشتباه گرفته نشود. */
}
.article-body h3 {
font-size: 14px;
}
/* متن مقاله در هر دو زبان هم‌تراز (justify) است: لبه‌های راست و چپ یکدست می‌مانند.
text-align: justify با direction درست، در فارسی و انگلیسی هر دو کار می‌کند. */
[data-dir='rtl'] .article-body {
direction: rtl;
text-align: right;
text-align: justify;
text-justify: inter-word;
}
[data-dir='ltr'] .article-body {
direction: ltr;
text-align: left;
text-align: justify;
text-justify: inter-word;
font-size: 12px;
}
/* تیترها و فهرست‌ها هم‌تراز نمی‌شوند — فقط متن پاراگراف تراز می‌شود
(تیتر کوتاه با justify کشیده و بدقلق می‌شود و فهرست نقطه‌گذاری‌اش به‌هم می‌ریزد). */
[data-dir='rtl'] .article-body h1,
[data-dir='rtl'] .article-body h2,
[data-dir='rtl'] .article-body h3,
[data-dir='rtl'] .article-body li,
[data-dir='rtl'] .article-body figcaption {
text-align: right;
}
[data-dir='ltr'] .article-body h1,
[data-dir='ltr'] .article-body h2,
[data-dir='ltr'] .article-body h3,
[data-dir='ltr'] .article-body li,
[data-dir='ltr'] .article-body figcaption {
text-align: left;
}
[data-dir='ltr'] .article-body h1 {
font-size: 16px;
}
@@ -259,7 +280,8 @@ html[lang='en'] .article-body h3 {
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
text-align: right;
/* تراز از جهت بلوک پیروی می‌کند — در مقاله انگلیسی چپ و در فارسی راست */
text-align: start;
}
[data-wysiwyg] .article-figure-node.is-selected .article-figure {
outline: 1px dashed rgba(213, 135, 76, 0.8);