Import source: app/backend/src/lib/link-engine
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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<string> {
|
||||||
|
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, `<a href="/glossary/${term.slug}" class="glossary-link" title="${term.title}">$1</a>`);
|
||||||
|
replaced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaced) {
|
||||||
|
$(textNode).replaceWith(nodeText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $.html();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user