- مقالهها ستون status (draft/published) گرفتند — GET /api/site و همه انتهای عمومی فقط منتشرشده را برمیگردانند؛ autosave دیگر پیشنویس نیمهکاره را زنده نمیکند - کلمه کلیدی هدف چکلیست سئو فیلد seo_keyphrase شد (قبلاً localStorage بود) - robots.txt و sitemap.xml از بکاند + سرو /blog و /projects با تزریق title/description/OG/JSON-LD از دیتابیس برای خزندههای بدون JS (app/seo_pages.py) - فرانت: applySeo در seo.ts (OG/توییتر/canonical/JSON-LD)، ادغام پیشنویس برای ادمین، برچسب پیشنویس در وبلاگ، حالت خالی وبلاگ، دکمه انتشار در صفحه نوشتن - تغییر رمز ادمین POST /api/auth/change-password + دیالوگ «رمز» در نوار مدیریت - محدودسازی ورود: ۵ تلاش ناموفق در ۱۵ دقیقه از هر IP → 429 - ساخت دکمه + برای ردیف سئوی صفحههای بدون ردیف (SingletonPill) - تستهای pytest (۱۵ مورد) + پینکردن requirements + حذف package.json سرگردان فرانت
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
// تگهای سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
|
|
// محتوا از دیتابیس میآید؛ برای خزندههای بدون جاوااسکریپت، بکاند همان تگها را
|
|
// سمت سرور داخل index.html تزریق میکند (app/seo_pages.py).
|
|
|
|
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 {
|
|
if (pathname === '/') return 'home'
|
|
const seg = pathname.split('/')[1] ?? ''
|
|
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
|
|
// مسیرهای جزئی مثل /blog/{slug} شامل نمیشوند تا اثر خود صفحه جزئیات برسانند
|
|
return known.includes(seg) && pathname === `/${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)
|
|
}
|
|
|
|
function setJsonLd(data: Record<string, unknown> | null): void {
|
|
const id = 'seo-jsonld'
|
|
let el = document.getElementById(id)
|
|
if (!data) {
|
|
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'
|
|
jsonLd?: 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()
|
|
|
|
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('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()
|
|
}
|
|
setJsonLd(input.jsonLd ?? null)
|
|
}
|