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:
Alireza Hassani
2026-08-10 20:44:47 +03:30
co-authored by Cursor
parent 23ba6344f6
commit 2c4d02bd0e
2 changed files with 162 additions and 4 deletions
+153
View File
@@ -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(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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 (/&lt;\/?(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;
}
+9 -4
View File
@@ -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 13 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 24 short paragraphs and optionally a bullet list. No scripts, styles, or markdown.`;
Aim for 24 short paragraphs and optionally a bullet list.`;
const userPrompt = JSON.stringify({
instructions: dto.prompt.trim(),
@@ -190,7 +193,7 @@ Aim for 24 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)
: [];