mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Normalize AI product HTML for the rich text description editor.
Sanitize model output to an allowlisted fragment and convert plain/markdown fallbacks so description text stays inside the advanced editor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
23ba6344f6
commit
2c4d02bd0e
@@ -0,0 +1,153 @@
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'p',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
]);
|
||||
|
||||
function decodeBasicEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/gi, '&');
|
||||
}
|
||||
|
||||
function stripCodeFences(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const fenced = trimmed.match(/^```(?:html)?\s*([\s\S]*?)\s*```$/i);
|
||||
return fenced ? fenced[1].trim() : trimmed;
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function plainTextToHtml(value: string): string {
|
||||
const normalized = value.replace(/\r\n/g, '\n').trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
const blocks = normalized.split(/\n{2,}/);
|
||||
const htmlParts: string[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
const bulletLines = lines.filter((line) => /^([-*•]|\d+[.)])\s+/.test(line));
|
||||
if (bulletLines.length === lines.length && lines.length > 1) {
|
||||
const items = lines
|
||||
.map((line) => line.replace(/^([-*•]|\d+[.)])\s+/, ''))
|
||||
.map((item) => `<li>${escapeText(item)}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ul>${items}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
htmlParts.push(`<p>${escapeText(lines.join(' '))}</p>`);
|
||||
}
|
||||
|
||||
return htmlParts.join('') || `<p>${escapeText(normalized)}</p>`;
|
||||
}
|
||||
|
||||
function tokenizeHtml(value: string): Array<{ type: 'tag' | 'text'; value: string }> {
|
||||
const tokens: Array<{ type: 'tag' | 'text'; value: string }> = [];
|
||||
const pattern = /<\/?([a-zA-Z][\w:-]*)\b[^>]*>/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'text', value: value.slice(lastIndex, match.index) });
|
||||
}
|
||||
tokens.push({ type: 'tag', value: match[0] });
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < value.length) {
|
||||
tokens.push({ type: 'text', value: value.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function sanitizeHtmlFragment(value: string): string {
|
||||
// Drop script/style blocks entirely (including their text content).
|
||||
const withoutBlocks = value.replace(
|
||||
/<\s*(script|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi,
|
||||
'',
|
||||
);
|
||||
const tokens = tokenizeHtml(withoutBlocks);
|
||||
let output = '';
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'text') {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagMatch = token.value.match(/^<\/?([a-zA-Z][\w:-]*)\b[^>]*>$/);
|
||||
if (!tagMatch) continue;
|
||||
|
||||
const tagName = tagMatch[1].toLowerCase();
|
||||
if (!ALLOWED_TAGS.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tagName === 'br') {
|
||||
output += '<br>';
|
||||
continue;
|
||||
}
|
||||
|
||||
const isClosing = token.value.startsWith('</');
|
||||
output += isClosing ? `</${tagName}>` : `<${tagName}>`;
|
||||
}
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize model output into a safe HTML fragment for the rich text editor.
|
||||
* Prevents markdown/plain text and escaped/malformed markup from landing outside
|
||||
* or rendering as raw source in the advanced description field.
|
||||
*/
|
||||
export function normalizeAiDescriptionHtml(raw: string): string {
|
||||
let value = String(raw ?? '').trim();
|
||||
if (!value) return '';
|
||||
|
||||
value = stripCodeFences(value);
|
||||
|
||||
// Models sometimes return entity-escaped HTML inside the JSON string.
|
||||
if (/<\/?(p|ul|ol|li|strong|em|br)\b/i.test(value)) {
|
||||
value = decodeBasicEntities(value);
|
||||
}
|
||||
|
||||
const hasHtmlTags = /<\/?[a-zA-Z][\w:-]*\b[^>]*>/.test(value);
|
||||
if (!hasHtmlTags) {
|
||||
return plainTextToHtml(value);
|
||||
}
|
||||
|
||||
const sanitized = sanitizeHtmlFragment(value);
|
||||
if (!sanitized) {
|
||||
return plainTextToHtml(value.replace(/<[^>]+>/g, ' '));
|
||||
}
|
||||
|
||||
// If sanitization stripped everything useful to plain text, wrap it.
|
||||
if (!/<\/?[a-zA-Z]/.test(sanitized)) {
|
||||
return plainTextToHtml(sanitized);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
|
||||
|
||||
type TechnicalFormField = {
|
||||
key: string;
|
||||
@@ -150,13 +151,15 @@ Follow the user's instructions for language and tone.
|
||||
Keep the summary to 1–3 sentences. No HTML. No markdown.
|
||||
Prefer a technical, informative style: what the product is and why its traits matter.
|
||||
Never write sales CTAs, discounts, "buy now", or vague praise like "excellent choice" / "amazing experience".`
|
||||
: `You write e-commerce product descriptions as HTML for Iranian stores.
|
||||
: `You write e-commerce product descriptions as HTML for an advanced rich-text editor in Iranian stores.
|
||||
Return ONLY valid JSON: { "html": "..." }
|
||||
The "html" value must be a raw HTML fragment (real tags, not escaped entities, not markdown, not code fences).
|
||||
Allowed tags only: p, ul, ol, li, strong, em, br.
|
||||
Do not wrap in html/body/div. Do not include scripts, styles, classes, or attributes.
|
||||
Follow the user's instructions for language and tone.
|
||||
Prefer a technical, informative structure: what it is, key traits/composition, typical use.
|
||||
Never write sales CTAs, discounts, or vague marketing slogans.
|
||||
Aim for 2–4 short paragraphs and optionally a bullet list. No scripts, styles, or markdown.`;
|
||||
Aim for 2–4 short paragraphs and optionally a bullet list.`;
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
instructions: dto.prompt.trim(),
|
||||
@@ -190,7 +193,7 @@ Aim for 2–4 short paragraphs and optionally a bullet list. No scripts, styles,
|
||||
return { field: ProductAiFillField.summary, text };
|
||||
}
|
||||
|
||||
const html = String(parsed.html ?? '').trim();
|
||||
const html = normalizeAiDescriptionHtml(String(parsed.html ?? ''));
|
||||
if (!html) {
|
||||
throw new BadRequestException('AI returned an empty description');
|
||||
}
|
||||
@@ -284,7 +287,9 @@ Rules:
|
||||
: String(draft.nameFa ?? '').trim();
|
||||
|
||||
const summary = String(draft.summary ?? '').trim();
|
||||
const descriptionHtml = String(draft.descriptionHtml ?? '').trim();
|
||||
const descriptionHtml = normalizeAiDescriptionHtml(
|
||||
String(draft.descriptionHtml ?? ''),
|
||||
);
|
||||
const tags = Array.isArray(draft.tags)
|
||||
? draft.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 12)
|
||||
: [];
|
||||
|
||||
Reference in New Issue
Block a user