articles: auto-link company mentions to manufacturer directory

When an article body is rendered, scan it for any company name in
bb.tracked_companies (directory_visible=true) and wrap the first
mention of each company in
  <a class="company-mention" data-company-slug="...">Name</a>
linking to /manufacturers/<slug>. Caps at 12 mentions per article so
heavy press-release lists don't get visually noisy.

Match rules:
- Word-boundary, case-insensitive
- Skips text already inside <a>, <script>, <style>, code/pre/headings
- Skips any element with data-no-autolink="true"
- Longest name wins on overlap (Sony Pictures beats Sony)
- One link per company per article (first occurrence)

Hover behavior: a new CompanyMentionsHover client component mounts once
per article page, listens globally for [data-company-slug] hovers, and
pops a floating card showing logo, name, 2-sentence bio, sponsor/NAB/IBC
badges, HQ city, 3 recent stories, and links to the full profile +
external website. Preview data fetched from new endpoint
  /api/public/companies/[slug]/preview
with a client-side cache keyed by slug.

Includes non-advertisers — any visible directory entry gets linked,
which is the point. If a company is *also* a current banner advertiser
on broadcastbeat, the hover card adds a green "Sponsor" badge.

Styling: dotted underline that becomes solid on hover, slightly bolder
weight, accent color on hover. Subtle so multi-mention paragraphs don't
look like a link-soup.
This commit is contained in:
Ryan Salazar
2026-05-27 12:32:14 +00:00
parent c1ab36297c
commit b479b62331
5 changed files with 432 additions and 1 deletions

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
export const revalidate = 300;
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) return NextResponse.json({ error: "slug required" }, { status: 400 });
const svc = createAdminClient();
const { data: company, error } = await svc
.from("tracked_companies")
.select("id, slug, company_name, bio, logo_url, company_website, hq_city, hq_country, exhibits_nab, exhibits_ibc, featured, categories")
.eq("slug", slug)
.eq("directory_visible", true)
.maybeSingle();
if (error || !company) return NextResponse.json({ error: "not found" }, { status: 404 });
// Recent stories: title contains company name. Cap at 5, newest first.
const needle = String(company.company_name || "").trim();
let stories: any[] = [];
if (needle.length >= 3) {
const { data } = await svc
.from("wp_imported_posts")
.select("wp_slug, title, wp_published_at, featured_image")
.eq("status", "published")
.ilike("title", `%${needle.replace(/[%_]/g, "")}%`)
.order("wp_published_at", { ascending: false, nullsFirst: false })
.limit(5);
stories = (data || []).map((r: any) => ({
slug: r.wp_slug,
title: r.title,
date: r.wp_published_at,
image: r.featured_image,
}));
}
// Also check if they're a current advertiser on BB.
let isActiveAdvertiser = false;
try {
const advSvc = createAdminClient("adv");
const { count } = await advSvc
.from("active_advertisers_by_property")
.select("client_id", { count: "exact", head: true })
.eq("property", "broadcastbeat")
.eq("company_name_lower", needle.toLowerCase());
isActiveAdvertiser = (count || 0) > 0;
} catch { /* tolerate adv lookup failures */ }
// Trim bio to a couple sentences max for the popover.
const bioShort = (company.bio || "")
.split(/(?<=[.!?])\s+/)
.slice(0, 2)
.join(" ")
.slice(0, 320);
return NextResponse.json(
{
slug: company.slug,
name: company.company_name,
bio: bioShort,
logoUrl: company.logo_url,
website: company.company_website,
hq: [company.hq_city, company.hq_country].filter(Boolean).join(", "),
featured: !!company.featured,
exhibitsNab: !!company.exhibits_nab,
exhibitsIbc: !!company.exhibits_ibc,
categories: Array.isArray(company.categories) ? company.categories.slice(0, 4) : [],
isActiveAdvertiser,
stories,
},
{ headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
);
}

View File

@@ -6,6 +6,8 @@ import {
getLegacyRecentSlugs,
getLegacyRelatedArticles,
} from "@/lib/articles/legacy-source";
import { linkifyArticleHtml } from "@/lib/company-mentions";
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
import NewsArticleDetailClient from "./NewsArticleDetailClient";
// ISR: serve cached pages but revalidate hourly. New imported posts surface
@@ -89,6 +91,11 @@ export default async function NewsArticlePage({ params }: PageProps) {
redirect("/news");
}
// Auto-link company-name mentions in the article body to /manufacturers/<slug>
// with data-company-slug so the hover card can pop a preview.
const linkedContent = await linkifyArticleHtml(article.content || "");
const articleWithLinks: Article = { ...article, content: linkedContent };
const articleSchema = {
"@context": "https://schema.org",
"@type": "NewsArticle",
@@ -112,7 +119,8 @@ export default async function NewsArticlePage({ params }: PageProps) {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<NewsArticleDetailClient article={article} relatedArticles={related} />
<NewsArticleDetailClient article={articleWithLinks} relatedArticles={related} />
<CompanyMentionsHover />
</>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
type Preview = {
slug: string;
name: string;
bio: string;
logoUrl: string | null;
website: string | null;
hq: string;
featured: boolean;
exhibitsNab: boolean;
exhibitsIbc: boolean;
categories: string[];
isActiveAdvertiser: boolean;
stories: { slug: string; title: string; date: string | null; image: string | null }[];
};
const cache = new Map<string, Promise<Preview | null>>();
async function fetchPreview(slug: string): Promise<Preview | null> {
if (cache.has(slug)) return cache.get(slug)!;
const p = (async () => {
try {
const r = await fetch(`/api/public/companies/${encodeURIComponent(slug)}/preview`);
if (!r.ok) return null;
return (await r.json()) as Preview;
} catch {
return null;
}
})();
cache.set(slug, p);
return p;
}
/**
* Mount once per page. Listens for hover/focus on any [data-company-slug]
* element, fetches preview data, and pops a floating card.
*/
export default function CompanyMentionsHover() {
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [loading, setLoading] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number; align: "left" | "right" } | null>(null);
const hideTimer = useRef<number | null>(null);
const showTimer = useRef<number | null>(null);
useEffect(() => {
function onEnter(e: MouseEvent | FocusEvent) {
const t = (e.target as HTMLElement | null)?.closest?.("[data-company-slug]") as HTMLElement | null;
if (!t) return;
if (hideTimer.current) { window.clearTimeout(hideTimer.current); hideTimer.current = null; }
if (showTimer.current) window.clearTimeout(showTimer.current);
showTimer.current = window.setTimeout(async () => {
const slug = t.getAttribute("data-company-slug");
if (!slug) return;
setAnchor(t);
setLoading(true);
const data = await fetchPreview(slug);
setLoading(false);
if (!data) { setAnchor(null); return; }
setPreview(data);
const rect = t.getBoundingClientRect();
const cardWidth = 360;
const fitsRight = rect.left + cardWidth < window.innerWidth - 12;
setPos({
top: rect.bottom + window.scrollY + 6,
left: fitsRight ? rect.left + window.scrollX : Math.max(12, rect.right + window.scrollX - cardWidth),
align: fitsRight ? "left" : "right",
});
}, 220);
}
function onLeave(e: MouseEvent | FocusEvent) {
const related = (e as MouseEvent).relatedTarget as HTMLElement | null;
if (related && (related.closest?.("[data-company-card]") || related.closest?.("[data-company-slug]"))) return;
if (showTimer.current) { window.clearTimeout(showTimer.current); showTimer.current = null; }
if (hideTimer.current) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => { setAnchor(null); setPreview(null); setPos(null); }, 180);
}
document.addEventListener("mouseover", onEnter, true);
document.addEventListener("mouseout", onLeave, true);
document.addEventListener("focusin", onEnter, true);
document.addEventListener("focusout", onLeave, true);
return () => {
document.removeEventListener("mouseover", onEnter, true);
document.removeEventListener("mouseout", onLeave, true);
document.removeEventListener("focusin", onEnter, true);
document.removeEventListener("focusout", onLeave, true);
};
}, []);
if (!anchor || !pos) return null;
return (
<div
data-company-card="true"
role="dialog"
aria-label={preview?.name ? `${preview.name} preview` : "Company preview"}
style={{ position: "absolute", top: pos.top, left: pos.left, width: 360, zIndex: 999 }}
className="bg-white text-ink border border-ink/10 rounded-xl shadow-2xl p-4 text-sm"
onMouseEnter={() => { if (hideTimer.current) { window.clearTimeout(hideTimer.current); hideTimer.current = null; } }}
onMouseLeave={() => { hideTimer.current = window.setTimeout(() => { setAnchor(null); setPreview(null); setPos(null); }, 180); }}
>
{loading || !preview ? (
<div className="text-ink/50 text-xs py-2">Loading</div>
) : (
<>
<div className="flex items-start gap-3 mb-2">
{preview.logoUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={preview.logoUrl} alt="" className="w-12 h-12 rounded bg-ink/5 object-contain flex-shrink-0" loading="lazy" />
) : (
<div className="w-12 h-12 rounded bg-ink/5 flex items-center justify-center text-ink/40 font-bold flex-shrink-0">
{(preview.name || "?").trim().charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<Link href={`/manufacturers/${preview.slug}`} className="font-semibold text-ink hover:text-brand-primary block truncate">
{preview.name}
</Link>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap text-[10px]">
{preview.isActiveAdvertiser && (
<span className="bg-emerald-50 text-emerald-700 px-1.5 py-0.5 rounded font-semibold uppercase tracking-wider">Sponsor</span>
)}
{preview.exhibitsNab && (
<span className="bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-semibold uppercase tracking-wider">NAB 26</span>
)}
{preview.exhibitsIbc && (
<span className="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded font-semibold uppercase tracking-wider">IBC</span>
)}
{preview.hq && <span className="text-ink/60">{preview.hq}</span>}
</div>
</div>
</div>
{preview.bio && (
<p className="text-ink/70 text-xs leading-snug mb-3 line-clamp-4">{preview.bio}</p>
)}
{preview.stories.length > 0 && (
<div className="border-t border-ink/10 pt-2">
<div className="text-[10px] uppercase tracking-widest text-ink/50 font-bold mb-1.5">Recent coverage</div>
<ul className="space-y-1">
{preview.stories.slice(0, 3).map((s) => (
<li key={s.slug}>
<Link href={`/news/${s.slug}`} className="text-xs text-brand-primary hover:underline line-clamp-2">
{s.title}
</Link>
{s.date && <span className="text-[10px] text-ink/40 ml-1">· {new Date(s.date).toISOString().slice(0, 10)}</span>}
</li>
))}
</ul>
</div>
)}
<div className="border-t border-ink/10 mt-3 pt-2 flex items-center justify-between">
<Link href={`/manufacturers/${preview.slug}`} className="text-xs font-semibold text-brand-primary hover:underline">
Full profile
</Link>
{preview.website && (
<a href={preview.website} target="_blank" rel="noopener noreferrer" className="text-[10px] text-ink/50 hover:text-brand-primary">
{preview.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}
</a>
)}
</div>
</>
)}
</div>
);
}

158
src/lib/company-mentions.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Auto-link company names in article body HTML to their manufacturer
* directory profiles. Mentions are wrapped in
* <a href="/manufacturers/<slug>" data-company-slug="<slug>" class="company-mention">Name</a>
* 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 <a> 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<DirectoryCompany[]> {
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 <a>, <script>, <style>,
* <code>, <pre>, headings, and any node carrying data-no-autolink="true".
*/
export function linkifyCompanyMentions(html: string, companies: DirectoryCompany[]): string {
if (!html || companies.length === 0) return html;
const $ = cheerio.load(html, { decodeEntities: false }, false);
const used = new Set<string>(); // slugs already linked
let total = 0;
// Pre-scan existing <a> texts so we don't re-link a company already linked
$("a").each((_, el) => {
const t = $(el).text().toLowerCase();
for (const c of companies) {
if (t.includes(c.needle)) used.add(c.slug);
}
});
const SKIP_TAGS = new Set(["a", "script", "style", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]);
function processNode(node: any): void {
if (total >= MAX_LINKS_PER_ARTICLE) return;
if (!node) return;
if (node.type === "tag") {
if (SKIP_TAGS.has(node.name)) return;
if (node.attribs && node.attribs["data-no-autolink"] === "true") return;
const kids = node.children ? [...node.children] : [];
for (const k of kids) processNode(k);
return;
}
if (node.type !== "text") return;
const text: string = node.data || "";
if (!text.trim()) return;
// Find the earliest, longest match among unlinked companies
let bestStart = -1;
let bestEnd = -1;
let bestCompany: DirectoryCompany | null = null;
for (const c of companies) {
if (used.has(c.slug)) continue;
if (total >= MAX_LINKS_PER_ARTICLE) break;
// Word-boundary, case-insensitive. \b doesn't work great for some
// names with punctuation (e.g. "Aja.com"); we use a lookaround.
const re = new RegExp(`(^|[^A-Za-z0-9])(${escapeRegex(c.name)})(?![A-Za-z0-9])`, "i");
const m = re.exec(text);
if (!m) continue;
const matchStart = m.index + m[1].length;
const matchEnd = matchStart + m[2].length;
// Prefer earlier match; on tie prefer longer name (companies are sorted
// longest-first, so first hit wins)
if (bestStart === -1 || matchStart < bestStart || (matchStart === bestStart && matchEnd - matchStart > bestEnd - bestStart)) {
bestStart = matchStart;
bestEnd = matchEnd;
bestCompany = c;
}
}
if (!bestCompany || bestStart < 0) return;
const before = text.slice(0, bestStart);
const matched = text.slice(bestStart, bestEnd);
const after = text.slice(bestEnd);
// Replace this text node with: before + <a> + after, then continue
// processing the "after" portion so additional companies in the same
// text node can also link.
const $a = $(
`<a href="/manufacturers/${bestCompany.slug}" data-company-slug="${bestCompany.slug}" class="company-mention">${$.text([{ type: "text", data: matched } as any])}</a>`,
);
const beforeNode = before ? { type: "text", data: before } : null;
const afterNode = after ? { type: "text", data: after } : null;
const parent = node.parent;
if (!parent) return;
const idx = parent.children.indexOf(node);
if (idx < 0) return;
const newChildren: any[] = [];
if (beforeNode) newChildren.push(beforeNode);
newChildren.push($a[0]);
if (afterNode) newChildren.push(afterNode);
parent.children.splice(idx, 1, ...newChildren);
used.add(bestCompany.slug);
total++;
if (afterNode) processNode(afterNode); // recurse into the leftover text
}
$.root().children().each((_, el) => processNode(el));
return $.html();
}
/** Convenience: load companies + linkify in one call. */
export async function linkifyArticleHtml(html: string): Promise<string> {
const companies = await loadDirectoryCompanies();
return linkifyCompanyMentions(html, companies);
}

View File

@@ -591,6 +591,25 @@ h1, h2, h3 {
color: var(--color-accent-dark);
}
/* Auto-linked company mentions (manufacturer directory cross-links).
Subtler than a generic link so they don't visually swamp the body copy
when several appear in one paragraph. */
.article-body a.company-mention,
a.company-mention {
color: #e0e0e0;
text-decoration: none;
border-bottom: 1px dotted var(--color-accent);
font-weight: 600;
cursor: help;
transition: color var(--transition-fast), border-color var(--transition-fast);
}
.article-body a.company-mention:hover,
a.company-mention:hover {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
border-bottom-style: solid;
}
.article-body blockquote {
border-left: 3px solid var(--color-accent);
padding-left: 1rem;