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 = { 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 = { 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' }