cross-link: companies-in-this-story sidebar + unified company search
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.
This commit is contained in:
@@ -8,9 +8,9 @@ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|||||||
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
|
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
|
||||||
|
|
||||||
function client() {
|
function client(schema = SCHEMA) {
|
||||||
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||||
db: { schema: SCHEMA as "public" },
|
db: { schema: schema as "public" },
|
||||||
auth: { persistSession: false },
|
auth: { persistSession: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -50,11 +50,51 @@ export async function GET(req: Request) {
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = (searchParams.get("q") || "").trim();
|
const q = (searchParams.get("q") || "").trim();
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
return NextResponse.json({ items: [] });
|
return NextResponse.json({ items: [], companies: [] });
|
||||||
}
|
}
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") || "8", 10) || 8, 15);
|
const limit = Math.min(parseInt(searchParams.get("limit") || "8", 10) || 8, 15);
|
||||||
const like = `%${q.replace(/[%_]/g, " ")}%`;
|
const like = `%${q.replace(/[%_]/g, " ")}%`;
|
||||||
|
|
||||||
|
// Parallel: matching directory companies (sponsors first).
|
||||||
|
const companiesPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const sb = client();
|
||||||
|
const { data } = await sb
|
||||||
|
.from("tracked_companies")
|
||||||
|
.select("slug, company_name, logo_url, exhibits_nab, exhibits_ibc")
|
||||||
|
.eq("directory_visible", true)
|
||||||
|
.ilike("company_name", like)
|
||||||
|
.order("featured", { ascending: false })
|
||||||
|
.order("mention_count", { ascending: false })
|
||||||
|
.limit(6);
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
let sponsors = new Set<string>();
|
||||||
|
try {
|
||||||
|
const adv = client("adv");
|
||||||
|
const { data: ads } = await adv
|
||||||
|
.from("active_advertisers_by_property")
|
||||||
|
.select("company_name_lower")
|
||||||
|
.eq("property", "broadcastbeat");
|
||||||
|
sponsors = new Set((ads || []).map((r: any) => String(r.company_name_lower)));
|
||||||
|
} catch { /* tolerate */ }
|
||||||
|
|
||||||
|
const rows = (data as any[]).map((r) => ({
|
||||||
|
slug: r.slug as string,
|
||||||
|
name: r.company_name as string,
|
||||||
|
logoUrl: (r.logo_url as string) || null,
|
||||||
|
exhibitsNab: !!r.exhibits_nab,
|
||||||
|
exhibitsIbc: !!r.exhibits_ibc,
|
||||||
|
isSponsor: sponsors.has(String(r.company_name || "").toLowerCase()),
|
||||||
|
href: `/manufacturers/${r.slug}`,
|
||||||
|
}));
|
||||||
|
rows.sort((a, b) => (b.isSponsor ? 1 : 0) - (a.isSponsor ? 1 : 0));
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const sb = client();
|
const sb = client();
|
||||||
// Match against title, excerpt, content, AND author name (was just
|
// Match against title, excerpt, content, AND author name (was just
|
||||||
// title+author). Content match is what surfaces hits when the phrase
|
// title+author). Content match is what surfaces hits when the phrase
|
||||||
@@ -126,8 +166,10 @@ export async function GET(req: Request) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const companies = await companiesPromise;
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ items, query: q },
|
{ items, companies, query: q },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",
|
"Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { Article } from "@/lib/articles/types";
|
|||||||
interface NewsArticleDetailClientProps {
|
interface NewsArticleDetailClientProps {
|
||||||
article: Article;
|
article: Article;
|
||||||
relatedArticles: Article[];
|
relatedArticles: Article[];
|
||||||
|
companiesInStory?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSessionId(): string {
|
function getSessionId(): string {
|
||||||
@@ -29,6 +30,7 @@ function getSessionId(): string {
|
|||||||
export default function NewsArticleDetailClient({
|
export default function NewsArticleDetailClient({
|
||||||
article,
|
article,
|
||||||
relatedArticles,
|
relatedArticles,
|
||||||
|
companiesInStory,
|
||||||
}: NewsArticleDetailClientProps) {
|
}: NewsArticleDetailClientProps) {
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
|
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
|
||||||
@@ -519,6 +521,9 @@ export default function NewsArticleDetailClient({
|
|||||||
{/* Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
|
{/* Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
|
||||||
<SidebarAdStack />
|
<SidebarAdStack />
|
||||||
|
|
||||||
|
{/* Companies referenced in the article body */}
|
||||||
|
{companiesInStory}
|
||||||
|
|
||||||
{/* Related Articles Sidebar */}
|
{/* Related Articles Sidebar */}
|
||||||
<div className="bg-[#111] border border-[#222] p-5">
|
<div className="bg-[#111] border border-[#222] p-5">
|
||||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
getLegacyRecentSlugs,
|
getLegacyRecentSlugs,
|
||||||
getLegacyRelatedArticles,
|
getLegacyRelatedArticles,
|
||||||
} from "@/lib/articles/legacy-source";
|
} from "@/lib/articles/legacy-source";
|
||||||
import { linkifyArticleHtml } from "@/lib/company-mentions";
|
import { linkifyAndExtractMentions } from "@/lib/company-mentions";
|
||||||
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
||||||
|
import CompaniesInThisStory from "@/components/CompaniesInThisStory";
|
||||||
import NewsArticleDetailClient from "./NewsArticleDetailClient";
|
import NewsArticleDetailClient from "./NewsArticleDetailClient";
|
||||||
|
|
||||||
// ISR: serve cached pages but revalidate hourly. New imported posts surface
|
// ISR: serve cached pages but revalidate hourly. New imported posts surface
|
||||||
@@ -91,9 +92,9 @@ export default async function NewsArticlePage({ params }: PageProps) {
|
|||||||
redirect("/news");
|
redirect("/news");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-link company-name mentions in the article body to /manufacturers/<slug>
|
// Auto-link company-name mentions in the article body and capture the set
|
||||||
// with data-company-slug so the hover card can pop a preview.
|
// of matched slugs for the "Companies in this story" sidebar.
|
||||||
const linkedContent = await linkifyArticleHtml(article.content || "");
|
const { html: linkedContent, mentionedSlugs } = await linkifyAndExtractMentions(article.content || "");
|
||||||
const articleWithLinks: Article = { ...article, content: linkedContent };
|
const articleWithLinks: Article = { ...article, content: linkedContent };
|
||||||
|
|
||||||
const articleSchema = {
|
const articleSchema = {
|
||||||
@@ -119,7 +120,15 @@ export default async function NewsArticlePage({ params }: PageProps) {
|
|||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||||
/>
|
/>
|
||||||
<NewsArticleDetailClient article={articleWithLinks} relatedArticles={related} />
|
<NewsArticleDetailClient
|
||||||
|
article={articleWithLinks}
|
||||||
|
relatedArticles={related}
|
||||||
|
companiesInStory={
|
||||||
|
mentionedSlugs.length > 0 ? (
|
||||||
|
<CompaniesInThisStory slugs={mentionedSlugs} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<CompanyMentionsHover />
|
<CompanyMentionsHover />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
119
src/components/CompaniesInThisStory.tsx
Normal file
119
src/components/CompaniesInThisStory.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
|
interface CompanyTile {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
bioShort: string;
|
||||||
|
isActiveAdvertiser: boolean;
|
||||||
|
exhibitsNab: boolean;
|
||||||
|
exhibitsIbc: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTiles(slugs: string[]): Promise<CompanyTile[]> {
|
||||||
|
if (slugs.length === 0) return [];
|
||||||
|
try {
|
||||||
|
const svc = createAdminClient();
|
||||||
|
const { data } = await svc
|
||||||
|
.from("tracked_companies")
|
||||||
|
.select("slug, company_name, logo_url, bio, exhibits_nab, exhibits_ibc")
|
||||||
|
.in("slug", slugs);
|
||||||
|
|
||||||
|
// Active advertiser lookup (set of company_name_lower) in adv schema.
|
||||||
|
let advNames = new Set<string>();
|
||||||
|
try {
|
||||||
|
const adv = createAdminClient("adv");
|
||||||
|
const { data: ads } = await adv
|
||||||
|
.from("active_advertisers_by_property")
|
||||||
|
.select("company_name_lower")
|
||||||
|
.eq("property", "broadcastbeat");
|
||||||
|
advNames = new Set((ads || []).map((r: any) => r.company_name_lower));
|
||||||
|
} catch { /* tolerate */ }
|
||||||
|
|
||||||
|
const bySlug = new Map<string, CompanyTile>();
|
||||||
|
for (const r of (data || []) as any[]) {
|
||||||
|
const name = String(r.company_name || "").trim();
|
||||||
|
bySlug.set(r.slug, {
|
||||||
|
slug: r.slug,
|
||||||
|
name,
|
||||||
|
logoUrl: r.logo_url || null,
|
||||||
|
bioShort: (r.bio || "").split(/(?<=[.!?])\s+/).slice(0, 1).join(" ").slice(0, 160),
|
||||||
|
isActiveAdvertiser: advNames.has(name.toLowerCase()),
|
||||||
|
exhibitsNab: !!r.exhibits_nab,
|
||||||
|
exhibitsIbc: !!r.exhibits_ibc,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Preserve the mention order
|
||||||
|
return slugs.map((s) => bySlug.get(s)).filter(Boolean) as CompanyTile[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CompaniesInThisStory({ slugs }: { slugs: string[] }) {
|
||||||
|
const tiles = await loadTiles(slugs);
|
||||||
|
if (tiles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#111] border border-[#222] p-5">
|
||||||
|
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||||
|
Companies in this story
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{tiles.map((t) => (
|
||||||
|
<li key={t.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/manufacturers/${t.slug}`}
|
||||||
|
data-company-slug={t.slug}
|
||||||
|
className="flex items-start gap-3 group hover:bg-[#161616] -mx-2 px-2 py-1.5 rounded-sm"
|
||||||
|
>
|
||||||
|
{t.logoUrl ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={t.logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded bg-white p-1 object-contain flex-shrink-0"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded bg-[#222] flex items-center justify-center text-[#666] font-bold text-sm flex-shrink-0">
|
||||||
|
{(t.name || "?").trim().charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
<span className="font-heading text-[#e0e0e0] text-sm font-bold group-hover:text-[#3b82f6] transition-colors truncate">
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap mt-0.5 text-[9px] uppercase tracking-wider font-semibold">
|
||||||
|
{t.isActiveAdvertiser && (
|
||||||
|
<span className="bg-emerald-500/15 text-emerald-300 px-1.5 py-0.5 rounded">
|
||||||
|
Sponsor
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.exhibitsNab && (
|
||||||
|
<span className="bg-amber-500/15 text-amber-300 px-1.5 py-0.5 rounded">
|
||||||
|
NAB 26
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{t.exhibitsIbc && (
|
||||||
|
<span className="bg-blue-500/15 text-blue-300 px-1.5 py-0.5 rounded">
|
||||||
|
IBC
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{t.bioShort && (
|
||||||
|
<p className="text-[#888] text-[11px] leading-snug mt-1 line-clamp-2">
|
||||||
|
{t.bioShort}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,17 @@ export default function Header() {
|
|||||||
href: string;
|
href: string;
|
||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
|
const [suggestedCompanies, setSuggestedCompanies] = useState<
|
||||||
|
Array<{
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
exhibitsNab: boolean;
|
||||||
|
exhibitsIbc: boolean;
|
||||||
|
isSponsor: boolean;
|
||||||
|
href: string;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
const [suggestOpen, setSuggestOpen] = useState(false);
|
const [suggestOpen, setSuggestOpen] = useState(false);
|
||||||
|
|
||||||
const submitSearch = () => {
|
const submitSearch = () => {
|
||||||
@@ -70,12 +81,13 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=8`, { cache: "no-store" })
|
fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=8`, { cache: "no-store" })
|
||||||
.then((r) => r.ok ? r.json() : { items: [] })
|
.then((r) => r.ok ? r.json() : { items: [], companies: [] })
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
setSuggestions(d.items || []);
|
setSuggestions(d.items || []);
|
||||||
|
setSuggestedCompanies(d.companies || []);
|
||||||
setSuggestOpen(true);
|
setSuggestOpen(true);
|
||||||
})
|
})
|
||||||
.catch(() => setSuggestions([]));
|
.catch(() => { setSuggestions([]); setSuggestedCompanies([]); });
|
||||||
}, 150);
|
}, 150);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
@@ -573,11 +585,49 @@ export default function Header() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{suggestOpen && suggestions.length > 0 && (
|
{suggestOpen && (suggestions.length > 0 || suggestedCompanies.length > 0) && (
|
||||||
<ul
|
<ul
|
||||||
id="bb-search-suggest"
|
id="bb-search-suggest"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="bb-browse-search-suggest">
|
className="bb-browse-search-suggest">
|
||||||
|
{suggestedCompanies.length > 0 && (
|
||||||
|
<>
|
||||||
|
<li className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-widest text-[#666] font-bold border-b border-[#1a1a1a]">
|
||||||
|
Companies
|
||||||
|
</li>
|
||||||
|
{suggestedCompanies.map((c) => (
|
||||||
|
<li key={`co-${c.slug}`} role="option">
|
||||||
|
<Link
|
||||||
|
href={c.href}
|
||||||
|
data-company-slug={c.slug}
|
||||||
|
className="bb-browse-search-suggest-item flex items-center gap-2.5"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
|
||||||
|
{c.logoUrl ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img src={c.logoUrl} alt="" className="w-7 h-7 rounded bg-white p-0.5 object-contain flex-shrink-0" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded bg-[#222] flex items-center justify-center text-[#666] font-bold text-xs flex-shrink-0">
|
||||||
|
{(c.name || "?").trim().charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 min-w-0 truncate font-semibold text-[#e0e0e0]">{c.name}</span>
|
||||||
|
{c.isSponsor && (
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider bg-emerald-500/15 text-emerald-300 px-1.5 py-0.5 rounded">Sponsor</span>
|
||||||
|
)}
|
||||||
|
{c.exhibitsNab && !c.isSponsor && (
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider bg-amber-500/15 text-amber-300 px-1.5 py-0.5 rounded">NAB 26</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<li className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-widest text-[#666] font-bold border-y border-[#1a1a1a]">
|
||||||
|
Stories
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{suggestions.map((s) => {
|
{suggestions.map((s) => {
|
||||||
const snippet = s.snippet;
|
const snippet = s.snippet;
|
||||||
const beforeMatch = snippet?.text.slice(0, snippet.matchStart) || "";
|
const beforeMatch = snippet?.text.slice(0, snippet.matchStart) || "";
|
||||||
|
|||||||
@@ -156,3 +156,53 @@ export async function linkifyArticleHtml(html: string): Promise<string> {
|
|||||||
const companies = await loadDirectoryCompanies();
|
const companies = await loadDirectoryCompanies();
|
||||||
return linkifyCompanyMentions(html, companies);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user