/**
* 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 ,