feat: SEO SPA editor integration

This commit is contained in:
Florian Sylvain committed 2026-10-16 03:06:57 +02:00
1 parent eb9876fde3
commit 470584400f
62 files changed
+7471 -58

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
import { charCount } from '@/lib/articleSeo'
import type { ArticleSeo } from '@/lib/articleSeo'
import type { Site } from '@/composables/useSites'
export type PreviewDevice = 'desktop' | 'mobile'
export const PREVIEW_LIMITS: Record<PreviewDevice, { title: number; description: number }> = {
desktop: { title: 60, description: 160 },
mobile: { title: 70, description: 120 },
}
export const TITLE_RANGE = { min: 30, max: 60 }
export const DESCRIPTION_RANGE = { min: 70, max: 160 }
export const SOCIAL_TITLE_RANGE = { min: 0, max: 90 }
const DEFAULT_TEMPLATE = '%title% | %site%'
const SENTENCE_END = /^(.+?[.!?])(\s|$)/s
export type TitleSource = 'seo_title' | 'title'
export type DescriptionSource = 'seo_description' | 'excerpt' | 'body' | 'site' | 'none'
export function truncateText(text: string, max: number) {
const characters = [...text]
return characters.length > max
? `${characters
.slice(0, max - 1)
.join('')
.trimEnd()}…`
: text
}
const BLOCK_BOUNDARY = /<\/?(p|div|h[1-6]|li|ul|ol|blockquote|pre|tr|br|hr)\b[^>]*>/gi
const SKIPPED_ELEMENTS = 'script, style'
export function bodyText(html: string) {
const parsed = new DOMParser().parseFromString(
html.replace(BLOCK_BOUNDARY, ' $& '),
'text/html',
)
parsed.querySelectorAll(SKIPPED_ELEMENTS).forEach((element) => element.remove())
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function firstSentence(html: string) {
const text = bodyText(html)
const match = SENTENCE_END.exec(text)
return match ? match[1]! : text
}
const TEMPLATE_TOKEN = /%title%|%site%/g
export function applyTitleTemplate(template: string, title: string, siteName: string) {
return (template || DEFAULT_TEMPLATE)
.replace(TEMPLATE_TOKEN, (token) => (token === '%title%' ? title : siteName))
.trim()
}
export function siteDisplayName(site: Site | undefined) {
return site?.site_name || site?.name || ''
}
export interface SeoPreviewInput {
seo: ArticleSeo
title: string
body: string
site: Site | undefined
}
export function previewTitleSource(input: SeoPreviewInput): TitleSource {
return input.seo.seo_title.trim() ? 'seo_title' : 'title'
}
export function previewTitle(input: SeoPreviewInput) {
const base = input.seo.seo_title.trim() || input.title
return applyTitleTemplate(input.site?.title_template ?? '', base, siteDisplayName(input.site))
}
export function previewDescriptionSource(input: SeoPreviewInput): DescriptionSource {
if (input.seo.seo_description.trim()) {
return 'seo_description'
}
if (input.seo.excerpt.trim()) {
return 'excerpt'
}
if (firstSentence(input.body)) {
return 'body'
}
return input.site?.default_description ? 'site' : 'none'
}
export function previewDescription(input: SeoPreviewInput) {
const sources: Record<DescriptionSource, string> = {
seo_description: input.seo.seo_description.trim(),
excerpt: input.seo.excerpt.trim(),
body: firstSentence(input.body),
site: input.site?.default_description ?? '',
none: '',
}
return sources[previewDescriptionSource(input)]
}
export function titleIsCut(title: string, device: PreviewDevice) {
return charCount(title) > PREVIEW_LIMITS[device].title
}
export function socialTitle(input: SeoPreviewInput) {
return input.seo.social_title.trim() || previewTitle(input)
}
export function socialDescription(input: SeoPreviewInput) {
return input.seo.social_description.trim() || previewDescription(input)
}
export function hasSocialOverrides(seo: ArticleSeo) {
return Boolean(seo.social_title || seo.social_description || seo.social_image_id !== null)
}
export type SocialImageSource = 'social' | 'cover' | 'site' | 'none'
export function socialImageSource(seo: ArticleSeo, siteHasImage: boolean): SocialImageSource {
if (seo.social_image_id !== null) {
return 'social'
}
if (seo.cover_image_id !== null) {
return 'cover'
}
return siteHasImage ? 'site' : 'none'
}