128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
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 }
|
|
|
|
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'
|
|
}
|