feat(blog): جدول — ستونها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگهای نسخه انگلیسی بزرگتر
This commit is contained in:
@@ -96,11 +96,14 @@ function AppChrome() {
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
const brand = data.brand.name;
|
||||
const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label;
|
||||
// alternate انگلیسی فقط وقتی اعلام میشود که متای انگلیسی واقعاً ثبت شده باشد
|
||||
const hasEn = !!(meta && ((meta.title_en ?? '').trim() || (meta.description_en ?? '').trim()));
|
||||
applySeo({
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
hasEn,
|
||||
});
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||
@@ -104,6 +104,7 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
meta_description_en: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
|
||||
@@ -19,6 +19,8 @@ export function ArticleCard({ article }: { article: Article }) {
|
||||
src={article.image}
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
|
||||
|
||||
@@ -13,6 +13,8 @@ type SseFrame = {
|
||||
content?: string;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
/** true یعنی دستیار چیزی پیدا نکرده و پیام جایگزین داده */
|
||||
fallback?: boolean;
|
||||
};
|
||||
|
||||
/** شناسه گفتگو برای هر نشست — در sessionStorage میماند تا رفرش صفحه تاریخچه را نگه دارد */
|
||||
@@ -78,7 +80,9 @@ export function ChatWidget() {
|
||||
const sendMessage = async (text: string) => {
|
||||
const question = text.trim();
|
||||
if (!question || isLoading) return;
|
||||
track('chat-question')
|
||||
// نتیجهٔ هر پرسش برای تحلیل گزارش میشود — پرسشهای بیپاسخ = شکاف دانش
|
||||
let hadError = false;
|
||||
let wasFallback = false;
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
setMessages((prev) => {
|
||||
@@ -111,6 +115,7 @@ export function ChatWidget() {
|
||||
let accumulated = '';
|
||||
await readSse(response, (frame) => {
|
||||
if (frame.error) {
|
||||
hadError = true;
|
||||
patchAssistant({ content: frame.error, streaming: false, error: true });
|
||||
return;
|
||||
}
|
||||
@@ -118,16 +123,21 @@ export function ChatWidget() {
|
||||
accumulated += frame.content;
|
||||
patchAssistant({ content: accumulated });
|
||||
}
|
||||
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||
if (frame.done) {
|
||||
wasFallback = frame.fallback === true;
|
||||
patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
|
||||
}
|
||||
});
|
||||
patchAssistant({ streaming: false });
|
||||
} catch (err) {
|
||||
hadError = true;
|
||||
patchAssistant({
|
||||
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
|
||||
streaming: false,
|
||||
error: true,
|
||||
});
|
||||
} finally {
|
||||
track('chat-question', { fallback: wasFallback, error: hadError });
|
||||
setIsLoading(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ContactSection() {
|
||||
src={brand.contact_bg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
className="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75 backdrop-blur-[2px]" />
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Footer() {
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" loading="lazy" decoding="async" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
|
||||
|
||||
@@ -27,7 +27,7 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
|
||||
return (
|
||||
<section className="relative flex h-[58vh] min-h-[420px] items-end overflow-hidden">
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />}
|
||||
{hero.image && <img src={hero.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />}
|
||||
|
||||
<div className="absolute inset-0 bg-ink/50" aria-hidden="true" />
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ export function Preloader() {
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
const total = 1800
|
||||
// کوتاه — فقط حس برند؛ تأخیر مصنوعی طولانی LCP/INP را خراب میکند
|
||||
const total = 700
|
||||
const id = window.setInterval(() => {
|
||||
const p = Math.min(100, ((Date.now() - start) / total) * 100)
|
||||
setProgress(p)
|
||||
@@ -29,13 +30,6 @@ export function Preloader() {
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = done ? '' : 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [done])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!done && (
|
||||
|
||||
@@ -53,6 +53,8 @@ export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps)
|
||||
src={finalImageUrl}
|
||||
alt={title}
|
||||
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
Columns2Icon,
|
||||
Rows2Icon,
|
||||
PanelTopIcon,
|
||||
BoldIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
@@ -448,6 +451,35 @@ export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps)
|
||||
<ToolButton label="حذف جدول" onClick={() => editor.chain().focus().deleteTable().run()}>
|
||||
<TableIcon className="h-4 w-4 rotate-45" />
|
||||
</ToolButton>
|
||||
{editor.isActive('table') && (
|
||||
<>
|
||||
<ToolButton label="ستون قبل" onClick={() => editor.chain().focus().addColumnBefore().run()}>
|
||||
<Columns2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ستون بعد" onClick={() => editor.chain().focus().addColumnAfter().run()}>
|
||||
<Columns2Icon className="h-4 w-4 rotate-180" />
|
||||
</ToolButton>
|
||||
<ToolButton label="حذف ستون" onClick={() => editor.chain().focus().deleteColumn().run()}>
|
||||
<Columns2Icon className="h-4 w-4 opacity-60" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ردیف بالا" onClick={() => editor.chain().focus().addRowBefore().run()}>
|
||||
<Rows2Icon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
<ToolButton label="ردیف پایین" onClick={() => editor.chain().focus().addRowAfter().run()}>
|
||||
<Rows2Icon className="h-4 w-4 rotate-180" />
|
||||
</ToolButton>
|
||||
<ToolButton label="حذف ردیف" onClick={() => editor.chain().focus().deleteRow().run()}>
|
||||
<Rows2Icon className="h-4 w-4 opacity-60" />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label="سرستون"
|
||||
active={editor.isActive('table')}
|
||||
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
|
||||
>
|
||||
<PanelTopIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
<ToolButton label="بولد" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<BoldIcon className="h-4 w-4" />
|
||||
</ToolButton>
|
||||
|
||||
@@ -18,6 +18,12 @@ export const ArticleImage = Image.extend({
|
||||
parseHTML: (element) =>
|
||||
element.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
},
|
||||
// پیوند کپشن — کل متن figcaption به این نشانی لینک میشود
|
||||
caption_href: {
|
||||
default: '',
|
||||
parseHTML: (element) =>
|
||||
element.closest('figure')?.querySelector('figcaption a')?.getAttribute('href') ?? '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,6 +41,7 @@ export const ArticleImage = Image.extend({
|
||||
alt: img.getAttribute('alt'),
|
||||
title: img.getAttribute('title'),
|
||||
caption: element.querySelector('figcaption')?.textContent?.trim() ?? '',
|
||||
caption_href: element.querySelector('figcaption a')?.getAttribute('href') ?? '',
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -51,12 +58,20 @@ export const ArticleImage = Image.extend({
|
||||
]
|
||||
|
||||
// کپشن خالی → عنصر figcaption اصلاً رندر نمیشود (فقط تصویر میماند)
|
||||
const captionHref = (HTMLAttributes.caption_href as string | undefined)?.trim() ?? ''
|
||||
const figcaption: Array<string | Record<string, unknown>> = captionHref
|
||||
? [
|
||||
'a',
|
||||
{ href: captionHref, target: '_blank', rel: 'noopener noreferrer nofollow' },
|
||||
caption,
|
||||
]
|
||||
: [caption]
|
||||
return caption
|
||||
? [
|
||||
'figure',
|
||||
{ class: 'article-figure' },
|
||||
image,
|
||||
['figcaption', { class: 'article-figure__caption' }, caption],
|
||||
['figcaption', { class: 'article-figure__caption' }, ...figcaption],
|
||||
]
|
||||
: ['figure', { class: 'article-figure' }, image]
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ type ImageAttrs = {
|
||||
alt: string | null
|
||||
title: string | null
|
||||
caption: string | null
|
||||
caption_href?: string | null
|
||||
}
|
||||
|
||||
const DELETE_BUTTON_CLASS =
|
||||
@@ -45,7 +46,7 @@ function isExternalImage(src: string): boolean {
|
||||
* - دکمه X روی تصویر کل تصویر را حذف میکند.
|
||||
*/
|
||||
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
||||
const { src, alt, title, caption } = node.attrs as ImageAttrs
|
||||
const { src, alt, title, caption, caption_href: captionHref } = node.attrs as ImageAttrs & { caption_href?: string | null }
|
||||
const { runSave } = useAdmin()
|
||||
const hasCaption = Boolean((caption ?? '').trim())
|
||||
const [captionOpen, setCaptionOpen] = useState(true)
|
||||
@@ -64,7 +65,7 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
||||
|
||||
/** حذف کامل کپشن: عنصر figcaption و ورودیاش از DOM ویرایشگر بیرون میرود */
|
||||
const removeCaption = () => {
|
||||
updateAttributes({ caption: '' })
|
||||
updateAttributes({ caption: '', caption_href: '' })
|
||||
setCaptionOpen(false)
|
||||
}
|
||||
|
||||
@@ -150,6 +151,15 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
|
||||
aria-label="کپشن تصویر"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={captionHref ?? ''}
|
||||
onChange={(event) => updateAttributes({ caption_href: event.target.value })}
|
||||
placeholder="لینک کپشن (اختیاری) — مثل منبع عکس…"
|
||||
aria-label="لینک کپشن تصویر"
|
||||
dir="ltr"
|
||||
className={INLINE_FIELD_CLASS}
|
||||
/>
|
||||
{/* حذف کامل کپشن — تصویر دستنخورده میماند */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,8 @@ export type SeoInput = {
|
||||
excerpt: string
|
||||
body: ArticleBodyDoc | string[]
|
||||
keyphrase: string
|
||||
/** نام نویسنده — سیگنال E-E-A-T و اسکیمای Article */
|
||||
author?: string
|
||||
}
|
||||
|
||||
type Status = 'good' | 'ok' | 'bad'
|
||||
@@ -95,6 +97,34 @@ export function useSeoChecks(input: SeoInput): Check[] {
|
||||
else
|
||||
checks.push({ label: 'نشانی (slug)', status: 'good', hint: 'نشانی مناسب است.' })
|
||||
|
||||
// ——— سئوی مولد (GEO) — آمادهسازی برای ارجاع در پاسخهای هوش مصنوعی ———
|
||||
|
||||
// پاسخ مستقیم در ابتدای مقاله — دستیارهای هوشمند بخشهای خودبسندهٔ آغازین را نقل میکنند
|
||||
const firstPWords = firstParagraph(input.body).split(/\s+/).filter(Boolean).length
|
||||
if (firstPWords >= 20)
|
||||
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'good', hint: 'پاراگراف اول پاسخ نسبتاً کاملی میدهد — دقیقاً چیزی که هوش مصنوعی نقل میکند.' })
|
||||
else
|
||||
checks.push({ label: 'پاسخ مستقیم در ابتدا (GEO)', status: 'ok', hint: 'پاراگراف اول را پاسخوار بنویسید (حدود ۴۰ تا ۶۰ واژه، پاسخ مستقیم پرسش اصلی مقاله).' })
|
||||
|
||||
// آمار و عدد — قویترین سیگنال ارجاع هوش مصنوعی (بههمراه ذکر منبع)
|
||||
const numberTokens = words.filter((w) => /\d|[۰-۹]/.test(w)).length
|
||||
if (numberTokens >= 5)
|
||||
checks.push({ label: `آمار و اعداد (${numberTokens} مورد)`, status: 'good', hint: 'متن دادهمحور است — سیگنال قوی برای استناد.' })
|
||||
else
|
||||
checks.push({ label: 'آمار و اعداد', status: 'ok', hint: 'حداقل ۵ عدد یا درصد (با ذکر منبع) به متن اضافه کنید — محتوای دادهمحور بیش از همه ارجاع میگیرد.' })
|
||||
|
||||
// تیتر پرسشی — همراستا با الگوی پرسش کاربران و «People Also Ask»
|
||||
if (h2s.some((h) => h.includes('؟') || h.includes('?')))
|
||||
checks.push({ label: 'تیتر پرسشی', status: 'good', hint: 'حداقل یک تیتر به شکل پرسش است.' })
|
||||
else
|
||||
checks.push({ label: 'تیتر پرسشی', status: 'ok', hint: 'یک تیتر H2 پرسشی (با «؟») اضافه کنید — همانطور که کاربران میپرسند.' })
|
||||
|
||||
// نویسنده — سیگنال اعتماد و هویت روشن محتوا
|
||||
if ((input.author ?? '').trim())
|
||||
checks.push({ label: 'نویسنده', status: 'good', hint: 'نام نویسنده ثبت شده — در اسکیمای مقاله هم میآید.' })
|
||||
else
|
||||
checks.push({ label: 'نویسنده', status: 'ok', hint: 'نام نویسنده را بنویسید — سیگنال اعتماد (E-E-A-T) و در اسکیمای مقاله استفاده میشود.' })
|
||||
|
||||
return checks
|
||||
}, [input])
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export function BeforeAfter() {
|
||||
src={before_after.before}
|
||||
alt={before_after.before_label}
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -86,6 +88,8 @@ export function BeforeAfter() {
|
||||
src={before_after.after}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Gallery() {
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" loading="lazy" decoding="async"
|
||||
/>
|
||||
</button>
|
||||
</EditableItem>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
<img
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105"
|
||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux hover:scale-105" loading="lazy" decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -33,7 +33,9 @@ export function WhyUs() {
|
||||
<img
|
||||
src={heading.image}
|
||||
alt={heading.title}
|
||||
className="mt-10 h-80 w-full object-cover" />
|
||||
className="mt-10 h-80 w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -89,10 +89,21 @@ html[lang='en'] .article-body {
|
||||
html[lang='en'] .article-body p {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
html[lang='en'] .article-body h1,
|
||||
html[lang='en'] .article-body h2,
|
||||
html[lang='en'] .article-body h3 {
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* هدینگها نسبت به متن ۱۷px انگلیسی بزرگتر — همان نسبت نسخه فارسی */
|
||||
html[lang='en'] .article-body h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
html[lang='en'] .article-body h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
html[lang='en'] .article-body h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
::selection {
|
||||
background-color: #d5874c;
|
||||
color: #1e1a16;
|
||||
@@ -482,9 +493,37 @@ html[lang='en'] .article-body h3 {
|
||||
.article-body table,
|
||||
.ProseMirror table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.92em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ProseMirror td,
|
||||
.ProseMirror th {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ProseMirror .column-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: -1px;
|
||||
left: -2px;
|
||||
width: 4px;
|
||||
z-index: 20;
|
||||
background-color: rgba(213, 135, 76, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
.ProseMirror.resize-cursor {
|
||||
cursor: col-resize;
|
||||
}
|
||||
.ProseMirror .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
background: rgba(213, 135, 76, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
.article-body th,
|
||||
.article-body td,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ProjectImageSlider({ images, alt, className = '' }: ProjectImage
|
||||
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
|
||||
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
|
||||
@@ -13,9 +13,10 @@ import { useLang } from '../i18n/lang';
|
||||
import { applySeo } from '../seo';
|
||||
import { useEffect } from 'react';
|
||||
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
|
||||
import { docToText } from '../utils/tiptapDoc';
|
||||
import { docToText, docHasText } from '../utils/tiptapDoc';
|
||||
import { detectDirection } from '../utils/direction';
|
||||
import { wrapScriptRuns } from '../utils/bidiText';
|
||||
import { toPublishedISO } from '../utils/date';
|
||||
import type { Article } from '../data/site';
|
||||
|
||||
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل میکند — سند TipTap جدید یا پاراگرافهای قدیمی */
|
||||
@@ -44,28 +45,60 @@ export function ArticleDetail() {
|
||||
const { t, href, lang } = useLang();
|
||||
const article = articles.find((a) => a.slug === slug);
|
||||
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — همارز نسخهٔ سمت سرور (seo_pages.py)
|
||||
useEffect(() => {
|
||||
if (!article) return;
|
||||
const pageUrl = new URL(href(`/blog/${article.slug}`), 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;
|
||||
const articleSchema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description,
|
||||
image: article.image ? absolute(article.image) : undefined,
|
||||
inLanguage: lang,
|
||||
// نویسندهٔ انسانی ثبتشده → Person (سیگنال E-E-A-T)؛ وگرنه برند
|
||||
author: authorName
|
||||
? { '@type': 'Person', name: authorName }
|
||||
: { '@type': 'Organization', name: brand.name },
|
||||
publisher: { '@type': 'Organization', name: brand.name },
|
||||
mainEntityOfPage: pageUrl,
|
||||
};
|
||||
const published = toPublishedISO(article.date);
|
||||
if (published) articleSchema.datePublished = published;
|
||||
const breadcrumbSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
|
||||
{ '@type': 'ListItem', position: 2, name: t('blog'), item: absolute(href('/blog')) },
|
||||
{ '@type': 'ListItem', position: 3, name: article.title, item: pageUrl },
|
||||
],
|
||||
};
|
||||
// پرسشوپاسخهای پایان مقاله → FAQPage (برای موتورهای هوش مصنوعی)
|
||||
const faqEntities = (article.faqs ?? [])
|
||||
.filter((f) => (f.question ?? '').trim() && (f.answer ?? '').trim())
|
||||
.map((f) => ({
|
||||
'@type': 'Question',
|
||||
name: f.question.trim(),
|
||||
acceptedAnswer: { '@type': 'Answer', text: f.answer.trim() },
|
||||
}));
|
||||
const faqSchema = faqEntities.length
|
||||
? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqEntities }
|
||||
: null;
|
||||
applySeo({
|
||||
title: `${article.title} | ${brand.name}`,
|
||||
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
|
||||
description: article.meta_description || article.excerpt,
|
||||
description,
|
||||
path: href(`/blog/${article.slug}`),
|
||||
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,
|
||||
},
|
||||
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
||||
hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)),
|
||||
jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])],
|
||||
});
|
||||
}, [article?.id, lang, href]);
|
||||
|
||||
@@ -104,6 +137,8 @@ export function ArticleDetail() {
|
||||
alt={article.image_alt || article.title}
|
||||
title={article.image_title || undefined}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-ink/75" aria-hidden="true" />
|
||||
<div
|
||||
|
||||
@@ -80,7 +80,7 @@ export function Blog() {
|
||||
<img
|
||||
src={featured.image}
|
||||
alt={`${t('articleImagePrefix')} ${featured.title}`}
|
||||
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" fetchPriority="high" decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center p-8 lg:p-12">
|
||||
|
||||
@@ -21,15 +21,37 @@ export function ProjectDetail() {
|
||||
const statusLabel = useStatusLabel(project?.status ?? '')
|
||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||
|
||||
// عنوان تب، توضیح متا و OG از خود پروژه
|
||||
// عنوان تب، توضیح متا، OG و JSON-LD از خود پروژه — همارز نسخهٔ سمت سرور (seo_pages.py)
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
const absolute = (path: string) => new URL(path, window.location.origin).href;
|
||||
applySeo({
|
||||
title: `${project.title} | ${brand.name}`,
|
||||
description: project.summary,
|
||||
path: href(`/projects/${project.slug}`),
|
||||
image: project.image,
|
||||
lang,
|
||||
// alternate انگلیسی فقط با وجود ترجمهٔ واقعی
|
||||
hasEn: !!(project.title_en?.trim() || project.summary_en?.trim()),
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: project.title,
|
||||
description: project.summary,
|
||||
image: project.image ? absolute(project.image) : undefined,
|
||||
inLanguage: lang,
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) },
|
||||
{ '@type': 'ListItem', position: 2, name: t('projects'), item: absolute(href('/projects')) },
|
||||
{ '@type': 'ListItem', position: 3, name: project.title, item: absolute(href(`/projects/${project.slug}`)) },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [project?.id, lang, href]);
|
||||
|
||||
@@ -69,7 +91,7 @@ export function ProjectDetail() {
|
||||
return (
|
||||
<>
|
||||
<section className="relative flex h-[62vh] min-h-[440px] items-end overflow-hidden">
|
||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<img src={project.image} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover" fetchPriority="high" decoding="async" />
|
||||
<div className="absolute inset-0 bg-ink/72" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -147,6 +169,8 @@ export function ProjectDetail() {
|
||||
src={project.gallery[0] || project.image}
|
||||
alt=""
|
||||
className="aspect-[4/3] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
@@ -161,6 +185,8 @@ export function ProjectDetail() {
|
||||
src={img}
|
||||
alt=""
|
||||
className="aspect-square w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
+48
-16
@@ -1,10 +1,14 @@
|
||||
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD.
|
||||
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
||||
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
||||
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py) و JSON-LD صفحه داخل
|
||||
// همون <script id="seo-jsonld"> بازنویسی میشود — نسخهٔ تکراری ساخته نمیشود.
|
||||
|
||||
import { localeHref, langFromPathname } from './i18n/lang'
|
||||
import type { Lang } from './i18n/lang'
|
||||
|
||||
/** تصویر پیشفرض شبکههای اجتماعی — وقتی صفحه تصویر اختصاصی ندارد */
|
||||
export const DEFAULT_OG_IMAGE = '/og-default.jpg'
|
||||
|
||||
export function setMetaDescription(content: string): void {
|
||||
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
||||
if (!content.trim()) return
|
||||
@@ -38,6 +42,10 @@ function upsertMeta(attr: 'name' | 'property', key: string, content: string): vo
|
||||
el.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function removeMeta(attr: 'name' | 'property', key: string): void {
|
||||
document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)?.remove()
|
||||
}
|
||||
|
||||
function upsertCanonical(path: string): void {
|
||||
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||
if (!el) {
|
||||
@@ -48,15 +56,23 @@ function upsertCanonical(path: string): void {
|
||||
el.setAttribute('href', new URL(path, window.location.origin).href)
|
||||
}
|
||||
|
||||
const HREFLANGS: [Lang, string][] = [
|
||||
['fa', 'fa'],
|
||||
['en', 'en'],
|
||||
['fa', 'x-default'],
|
||||
]
|
||||
/** لینک alternate با hreflang مشخص را حذف میکند — وقتی ترجمهای وجود ندارد */
|
||||
function removeAlternate(hreflang: string): void {
|
||||
document.querySelector<HTMLLinkElement>(`link[data-hreflang="${hreflang}"]`)?.remove()
|
||||
}
|
||||
|
||||
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر */
|
||||
function upsertAlternates(path: string): void {
|
||||
for (const [targetLang, hreflang] of HREFLANGS) {
|
||||
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر.
|
||||
* مجموعهٔ کامل (fa/en/x-default) فقط وقتی اعلام میشود که ترجمهٔ انگلیسی واقعاً
|
||||
* وجود داشته باشد؛ مجموعهٔ ناسازگار کل hreflang را بیاعتبار میکند. */
|
||||
function upsertAlternates(path: string, lang: Lang, hasEn: boolean): void {
|
||||
// صفحهٔ انگلیسیِ ترجمهنشده هیچ ادعایی ندارد؛ صفحهٔ فارسی فقط خودش و x-default
|
||||
const targets: [Lang, string][] = hasEn
|
||||
? [['fa', 'fa'], ['en', 'en'], ['fa', 'x-default']]
|
||||
: lang === 'fa'
|
||||
? [['fa', 'fa'], ['fa', 'x-default']]
|
||||
: []
|
||||
if (!hasEn) removeAlternate('en')
|
||||
for (const [targetLang, hreflang] of targets) {
|
||||
// x-default همیشه نسخه فارسی است
|
||||
const target = localeHref(path, targetLang)
|
||||
const attr = `data-hreflang="${hreflang}"`
|
||||
@@ -93,12 +109,14 @@ export type SeoInput = {
|
||||
description?: string
|
||||
/** مسیر canonical — پیشفرض مسیر جاری مرورگر */
|
||||
path?: string
|
||||
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود */
|
||||
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود؛ بدون آن تصویر پیشفرض برند */
|
||||
image?: string
|
||||
type?: 'website' | 'article'
|
||||
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
||||
lang?: Lang
|
||||
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + FAQPage) */
|
||||
/** آیا ترجمهٔ انگلیسی همین صفحه واقعاً وجود دارد؟ (پیشفرض: بله — مثل رفتار قبلی) */
|
||||
hasEn?: boolean
|
||||
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + Breadcrumb + FAQPage) */
|
||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
||||
}
|
||||
|
||||
@@ -108,6 +126,7 @@ export function applySeo(input: SeoInput): void {
|
||||
const description = (input.description ?? '').trim()
|
||||
const path = input.path ?? window.location.pathname
|
||||
const image = (input.image ?? '').trim()
|
||||
const hasEn = input.hasEn ?? true
|
||||
const lang: Lang = input.lang ?? langFromPathname(path)
|
||||
|
||||
document.title = title
|
||||
@@ -118,12 +137,25 @@ export function applySeo(input: SeoInput): void {
|
||||
upsertMeta('property', 'og:type', input.type ?? 'website')
|
||||
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
|
||||
upsertMeta('property', 'og:locale', lang === 'en' ? 'en_US' : 'fa_IR')
|
||||
upsertMeta('property', 'og:locale:alternate', lang === 'en' ? 'fa_IR' : 'en_US')
|
||||
upsertMeta('name', 'twitter:card', 'summary_large_image')
|
||||
if (image) {
|
||||
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
|
||||
} else {
|
||||
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
|
||||
upsertMeta('name', 'twitter:title', title)
|
||||
upsertMeta('name', 'twitter:description', description)
|
||||
// تصویر OG — تصویر اختصاصی صفحه یا تصویر پیشفرض برند
|
||||
{
|
||||
const ogImage = new URL(image || DEFAULT_OG_IMAGE, window.location.origin).href
|
||||
upsertMeta('property', 'og:image', ogImage)
|
||||
upsertMeta('name', 'twitter:image', ogImage)
|
||||
upsertMeta('property', 'og:image:alt', title)
|
||||
if (image) {
|
||||
// ابعاد فقط برای تصویر پیشفرض قطعی است؛ تصویر اختصاصی ابعاد متغیر دارد
|
||||
removeMeta('property', 'og:image:width')
|
||||
removeMeta('property', 'og:image:height')
|
||||
} else {
|
||||
upsertMeta('property', 'og:image:width', '1200')
|
||||
upsertMeta('property', 'og:image:height', '630')
|
||||
}
|
||||
}
|
||||
upsertAlternates(path)
|
||||
upsertAlternates(path, lang, hasEn)
|
||||
setJsonLd(input.jsonLd ?? null)
|
||||
}
|
||||
|
||||
@@ -67,3 +67,33 @@ export function toISODate(value?: DateObject | Date | null): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${gregorianDate.year}-${pad(gregorianDate.month.number)}-${pad(gregorianDate.day)}`
|
||||
}
|
||||
|
||||
/** تاریخ مقاله → ISO میلادی برای datePublished اسکیمای JSON-LD؛ تاریخ نامفهوم → null.
|
||||
* هم ISO ذخیرهشده از انتخابگر پنل و هم شمسی دستنویس («۲۴ مرداد ۱۴۰۳») را میفهمد.
|
||||
* همارز تابع _parse_site_date سمت بکاند (app/seo_pages.py) است. */
|
||||
export function toPublishedISO(value?: string | null): string | null {
|
||||
const raw = (value ?? '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
if (ISO_DATE.test(raw)) {
|
||||
const parsed = new Date(raw)
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// شمسی دستنویس — ارقام فارسی به لاتین، نام ماه به شماره
|
||||
const latin = raw.replace(/[۰-۹]/g, (c) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c)))
|
||||
const m = latin.match(/^(\d{1,2})\s*([\u0600-\u06FF]+)\s*(\d{4})$/)
|
||||
const faMonths: Record<string, number> = {
|
||||
'فروردین': 1, 'اردیبهشت': 2, 'خرداد': 3, 'تیر': 4, 'مرداد': 5, 'شهریور': 6,
|
||||
'مهر': 7, 'آبان': 8, 'آذر': 9, 'دی': 10, 'بهمن': 11, 'اسفند': 12,
|
||||
}
|
||||
const month = m ? faMonths[m[2]] : undefined
|
||||
if (!m || !month) return null
|
||||
|
||||
const jalali = new DateObject({ calendar: persian }).set({
|
||||
year: Number(m[3]),
|
||||
month,
|
||||
day: Number(m[1]),
|
||||
})
|
||||
return jalali.isValid ? toISODate(jalali) : null
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ export function docToText(body: unknown): string {
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/** آیا سند متن واقعی دارد؟ (سند خالی پوسته یا null: false) */
|
||||
export function docHasText(body: unknown): boolean {
|
||||
return docToText(body).trim().length > 0
|
||||
}
|
||||
|
||||
/** متن تیترهای یک سطح مشخص (پیشفرض H2) */
|
||||
export function docHeadings(body: unknown, level = 2): string[] {
|
||||
if (Array.isArray(body)) return []
|
||||
|
||||
Reference in New Issue
Block a user