feat(blog): جدول — ستون‌ها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگ‌های نسخه انگلیسی بزرگ‌تر

This commit is contained in:
aramesh-site deploy
2026-10-06 10:57:01 +00:00
parent 61a2e95278
commit 1b491357ab
45 changed files with 2341 additions and 81 deletions
+48 -16
View File
@@ -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)
}