// SEO helpers for meta tags and JSON-LD. Strips HTML and decodes common // entities so seller-authored rich-text descriptions don't leak markup into // , og:description, twitter:description, or // schema.org Product.description. const NAMED_ENTITIES: Record = { nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", }; function decodeEntities(text: string): string { return text .replace(/&(nbsp|amp|lt|gt|quot|apos);/g, (_, name: string) => NAMED_ENTITIES[name] ?? '') .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16))); } // Strip HTML tags, decode entities, collapse whitespace. Idempotent on plain text. export function stripHtmlForSeo(input: string | null | undefined): string { if (!input) return ''; return decodeEntities(input.replace(/<[^>]*>/g, ' ')) .replace(/\s+/g, ' ') .trim(); } // Build a meta description from raw HTML/text. // Truncates at the nearest word boundary so we never cut mid-word, and // appends a Unicode ellipsis. Returns '' for null/empty input so callers // can fall back cleanly. export function buildMetaDescription(input: string | null | undefined, maxLength = 160): string { const stripped = stripHtmlForSeo(input); if (!stripped) return ''; if (stripped.length <= maxLength) return stripped; const room = maxLength - 1; // leave room for ellipsis const cut = stripped.slice(0, room); const lastSpace = cut.lastIndexOf(' '); // Only break on word boundary when it's reasonably close to the cap — otherwise // a single very long word would shrink the description to almost nothing. const safeCut = lastSpace > room * 0.7 ? cut.slice(0, lastSpace) : cut; return `${safeCut.trim()}…`; }