diff --git a/app/backend/src/lib/link-engine.ts b/app/backend/src/lib/link-engine.ts new file mode 100644 index 0000000..055211c --- /dev/null +++ b/app/backend/src/lib/link-engine.ts @@ -0,0 +1,70 @@ +import { PrismaClient } from '@prisma/client'; +import * as cheerio from 'cheerio'; + +const prisma = new PrismaClient(); + +/** + * Autolink glossary terms in the provided HTML content using cheerio. + * Safely traverses text nodes and wraps matching terms in anchor tags, + * avoiding already linked text, code blocks, and headings. + */ +export async function autolinkGlossaryTerms(htmlContent: string): Promise { + if (!htmlContent) return htmlContent; + + const glossaryTerms = await prisma.article.findMany({ + where: { type: 'glossary', status: 'منتشرشده' }, + select: { title: true, slug: true }, + }); + + if (glossaryTerms.length === 0) return htmlContent; + + // Sort by length descending to match longest phrases first + const sortedTerms = glossaryTerms.sort((a, b) => b.title.length - a.title.length); + + const $ = cheerio.load(htmlContent, null, false); + + // Exclude elements where we shouldn't inject links + const excludedTags = ['a', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'script', 'style']; + + // Find all text nodes that are not inside excluded tags + const textNodes: any[] = []; + $('*').contents().each(function() { + if (this.type === 'text') { + let parent = $(this).parent(); + let shouldExclude = false; + while (parent.length > 0 && parent[0].name) { + if (excludedTags.includes(parent[0].name.toLowerCase())) { + shouldExclude = true; + break; + } + parent = parent.parent(); + } + if (!shouldExclude) { + textNodes.push(this); + } + } + }); + + for (const textNode of textNodes) { + let nodeText = $(textNode).text(); + let replaced = false; + + for (const term of sortedTerms) { + const escapedTerm = term.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Using word boundaries; since Farsi spaces/zwnj might be tricky, we can use a simpler regex + // that matches the phrase separated by whitespace or punctuation. + const regex = new RegExp(`(?<=^|[\\s،؛.!?()»«])(${escapedTerm})(?=[\\s،؛.!?()»«]|$)`, 'gi'); + + if (regex.test(nodeText)) { + nodeText = nodeText.replace(regex, `$1`); + replaced = true; + } + } + + if (replaced) { + $(textNode).replaceWith(nodeText); + } + } + + return $.html(); +}