/** * Auto-link company names in article body HTML to their manufacturer * directory profiles. Mentions are wrapped in * Name * so a client-side hover-card component can pop a preview on hover. * * Linking respects the directory_visible flag, only links the FIRST * occurrence per company per article (to avoid clutter), and never * double-wraps existing tags. */ import * as cheerio from "cheerio"; import { createAdminClient } from "./supabase/admin"; export type DirectoryCompany = { name: string; slug: string; /** lower-case name used for matching */ needle: string; }; const MAX_LINKS_PER_ARTICLE = 12; let cached: { rows: DirectoryCompany[]; expires: number } | null = null; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — companies barely change export async function loadDirectoryCompanies(): Promise { if (cached && cached.expires > Date.now()) return cached.rows; try { const svc = createAdminClient(); // bb schema default const { data } = await svc .from("tracked_companies") .select("company_name, slug") .eq("directory_visible", true) .not("slug", "is", null) .not("company_name", "is", null); const rows: DirectoryCompany[] = (data || []) .filter((r: any) => r.company_name && r.slug) .map((r: any) => ({ name: String(r.company_name).trim(), slug: String(r.slug), needle: String(r.company_name).trim().toLowerCase(), })) .filter((r) => r.needle.length >= 3); // skip 2-char names — too noisy // Sort by longest name first so "Sony Pictures" wins over "Sony" rows.sort((a, b) => b.needle.length - a.needle.length); cached = { rows, expires: Date.now() + CACHE_TTL_MS }; return rows; } catch { return cached?.rows || []; } } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Walk text nodes of the parsed HTML and replace the first occurrence of * each company name with a link. Skips text inside ,