- Alt/Title تصویر شاخص اکنون ذخیره میشود (ستونهای جدید image_alt/image_title — قبلاً بیصدا دور ریخته میشد) - Alt و Title تصاویر داخل متن پس از درج هم قابل ویرایشاند (کنار کپشن) - نام فایل ذخیرهسازی خوانا: نام دلخواه هنگام درج یا نام خود فایل (بهجای UUID خالی) - پنل پرسشوپاسخ پایان مقاله (ستونهای faqs/faqs_en) با ظاهر آکاردئونی سایت و اسکیمای FAQPage - چسباندن از Outline و هر منبع خارجی: تصاویر خودکار دانلود و در Object Storage سایت میزبانی میشوند (endpoint جدید import-image با محافظ SSRF) + دکمه میزبانی مجدد روی تصاویر خارجی موجود
130 lines
5.3 KiB
TypeScript
130 lines
5.3 KiB
TypeScript
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD.
|
|
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
|
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
|
|
|
import { localeHref, langFromPathname } from './i18n/lang'
|
|
import type { Lang } from './i18n/lang'
|
|
|
|
export function setMetaDescription(content: string): void {
|
|
// بدون توضیح از دیتابیس، پیشفرض استاتیک index.html دستنخورده میماند
|
|
if (!content.trim()) return
|
|
let el = document.querySelector<HTMLMetaElement>('meta[name="description"]')
|
|
if (!el) {
|
|
el = document.createElement('meta')
|
|
el.setAttribute('name', 'description')
|
|
document.head.appendChild(el)
|
|
}
|
|
el.setAttribute('content', content.trim())
|
|
}
|
|
|
|
/** کلید سئوی مسیر جاری — فقط صفحههای فهرست دقیق؛ صفحههای جزئیات خودشان عنوان میگذارند */
|
|
export function seoKeyForPath(pathname: string): string | null {
|
|
// زبان از پیشوند /en جدا میشود — کلیدهای سئو برای هر دو زبان یکی است
|
|
const clean = pathname === '/en' ? '/' : pathname.startsWith('/en/') ? pathname.slice(3) : pathname
|
|
if (clean === '/') return 'home'
|
|
const seg = clean.split('/')[1] ?? ''
|
|
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
|
|
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
|
return known.includes(seg) && clean === `/${seg}` ? seg : null
|
|
}
|
|
|
|
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
|
|
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${key}"]`)
|
|
if (!el) {
|
|
el = document.createElement('meta')
|
|
el.setAttribute(attr, key)
|
|
document.head.appendChild(el)
|
|
}
|
|
el.setAttribute('content', content)
|
|
}
|
|
|
|
function upsertCanonical(path: string): void {
|
|
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
|
if (!el) {
|
|
el = document.createElement('link')
|
|
el.setAttribute('rel', 'canonical')
|
|
document.head.appendChild(el)
|
|
}
|
|
el.setAttribute('href', new URL(path, window.location.origin).href)
|
|
}
|
|
|
|
const HREFLANGS: [Lang, string][] = [
|
|
['fa', 'fa'],
|
|
['en', 'en'],
|
|
['fa', 'x-default'],
|
|
]
|
|
|
|
/** لینکهای alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر */
|
|
function upsertAlternates(path: string): void {
|
|
for (const [targetLang, hreflang] of HREFLANGS) {
|
|
// x-default همیشه نسخه فارسی است
|
|
const target = localeHref(path, targetLang)
|
|
const attr = `data-hreflang="${hreflang}"`
|
|
let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`)
|
|
if (!el) {
|
|
el = document.createElement('link')
|
|
el.setAttribute('rel', 'alternate')
|
|
el.setAttribute('hreflang', hreflang)
|
|
el.setAttribute('data-hreflang', hreflang)
|
|
document.head.appendChild(el)
|
|
}
|
|
el.setAttribute('href', new URL(target, window.location.origin).href)
|
|
}
|
|
}
|
|
|
|
function setJsonLd(data: Record<string, unknown> | Record<string, unknown>[] | null): void {
|
|
const id = 'seo-jsonld'
|
|
let el = document.getElementById(id)
|
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
|
el?.remove()
|
|
return
|
|
}
|
|
if (!el) {
|
|
el = document.createElement('script')
|
|
el.setAttribute('type', 'application/ld+json')
|
|
el.id = id
|
|
document.head.appendChild(el)
|
|
}
|
|
el.textContent = JSON.stringify(data)
|
|
}
|
|
|
|
export type SeoInput = {
|
|
title: string
|
|
description?: string
|
|
/** مسیر canonical — پیشفرض مسیر جاری مرورگر */
|
|
path?: string
|
|
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل میشود */
|
|
image?: string
|
|
type?: 'website' | 'article'
|
|
/** زبان جاری — برای og:locale و لینکهای hreflang */
|
|
lang?: Lang
|
|
/** اسکیمای JSON-LD — یک شیء یا آرایهای از چند اسکیما (مثل Article + FAQPage) */
|
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[] | null
|
|
}
|
|
|
|
/** تنظیم همه تگهای سئوی صفحه در یک فراخوانی */
|
|
export function applySeo(input: SeoInput): void {
|
|
const title = input.title.trim()
|
|
const description = (input.description ?? '').trim()
|
|
const path = input.path ?? window.location.pathname
|
|
const image = (input.image ?? '').trim()
|
|
const lang: Lang = input.lang ?? langFromPathname(path)
|
|
|
|
document.title = title
|
|
setMetaDescription(description)
|
|
upsertCanonical(path)
|
|
upsertMeta('property', 'og:title', title)
|
|
upsertMeta('property', 'og:description', description)
|
|
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('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()
|
|
}
|
|
upsertAlternates(path)
|
|
setJsonLd(input.jsonLd ?? null)
|
|
}
|