Feature 1 — sidebar
Adds a "Companies in this story" panel on every news article that
surfaces every directory company my linker matched in the body. Each
tile shows logo, name, Sponsor / NAB 26 / IBC badges, and a one-sentence
bio. Tiles link to /manufacturers/<slug> and carry data-company-slug so
the global hover card pops on hover too.
Extends company-mentions.ts with linkifyAndExtractMentions() that
returns both the linked HTML and the ordered list of matched slugs in
one pass, so /news/[slug] doesn't re-scan the body for the sidebar.
Feature 2 — unified typeahead
/api/search/suggest now returns { companies, items } — companies are
matched against bb.tracked_companies (directory_visible=true), capped
at 6, ordered by featured → mention_count, then bumped if they're an
active advertiser on broadcastbeat (adv schema lookup).
Header search dropdown renders a "Companies" section at the top with
logo, name, Sponsor badge (active advertiser) or NAB 26 badge, above
the existing "Stories" results. Each company link carries
data-company-slug so the hover card works in the dropdown too.
209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Same as linkifyArticleHtml but also returns the set of company slugs that
|
|
* were matched (in the order they first appeared). Callers use this to
|
|
* render a "Companies in this story" sidebar without re-scanning the HTML.
|
|
*/
|
|
export async function linkifyAndExtractMentions(
|
|
html: string,
|
|
): Promise<{ html: string; mentionedSlugs: string[] }> {
|
|
const companies = await loadDirectoryCompanies();
|
|
if (!html || companies.length === 0) return { html: html || "", mentionedSlugs: [] };
|
|
|
|
// Re-run the same matching against the original (unmodified) HTML so we
|
|
// can capture the slug list. We need to dupe the logic a bit because the
|
|
// existing linkifyCompanyMentions function mutates as it goes.
|
|
const cheerio = require("cheerio");
|
|
const $ = cheerio.load(html, { decodeEntities: false }, false);
|
|
const matched: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
const SKIP_TAGS = new Set(["a", "script", "style", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
|
|
function scan(node: any): void {
|
|
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) scan(k);
|
|
return;
|
|
}
|
|
if (node.type !== "text") return;
|
|
const text: string = node.data || "";
|
|
if (!text.trim()) return;
|
|
for (const c of companies) {
|
|
if (seen.has(c.slug)) continue;
|
|
const re = new RegExp(`(^|[^A-Za-z0-9])(${c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})(?![A-Za-z0-9])`, "i");
|
|
if (re.test(text)) {
|
|
matched.push(c.slug);
|
|
seen.add(c.slug);
|
|
if (matched.length >= MAX_LINKS_PER_ARTICLE) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$.root().children().each((_: number, el: any) => scan(el));
|
|
|
|
const linkedHtml = linkifyCompanyMentions(html, companies);
|
|
return { html: linkedHtml, mentionedSlugs: matched };
|
|
}
|