feat(blog): جدول — ستونها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگهای نسخه انگلیسی بزرگتر
This commit is contained in:
+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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user