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,
|
requestAiJsonCompletion,
|
||||||
resolveAiProvider,
|
resolveAiProvider,
|
||||||
} from '../common/ai-provider.util';
|
} from '../common/ai-provider.util';
|
||||||
|
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
|
||||||
|
|
||||||
type TechnicalFormField = {
|
type TechnicalFormField = {
|
||||||
key: string;
|
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.
|
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.
|
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".`
|
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": "..." }
|
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.
|
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.
|
Follow the user's instructions for language and tone.
|
||||||
Prefer a technical, informative structure: what it is, key traits/composition, typical use.
|
Prefer a technical, informative structure: what it is, key traits/composition, typical use.
|
||||||
Never write sales CTAs, discounts, or vague marketing slogans.
|
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({
|
const userPrompt = JSON.stringify({
|
||||||
instructions: dto.prompt.trim(),
|
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 };
|
return { field: ProductAiFillField.summary, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = String(parsed.html ?? '').trim();
|
const html = normalizeAiDescriptionHtml(String(parsed.html ?? ''));
|
||||||
if (!html) {
|
if (!html) {
|
||||||
throw new BadRequestException('AI returned an empty description');
|
throw new BadRequestException('AI returned an empty description');
|
||||||
}
|
}
|
||||||
@@ -284,7 +287,9 @@ Rules:
|
|||||||
: String(draft.nameFa ?? '').trim();
|
: String(draft.nameFa ?? '').trim();
|
||||||
|
|
||||||
const summary = String(draft.summary ?? '').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)
|
const tags = Array.isArray(draft.tags)
|
||||||
? draft.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 12)
|
? draft.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 12)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
Reference in New Issue
Block a user