// 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(/([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()}…`;
}