diff --git a/src/common/ai-html.util.ts b/src/common/ai-html.util.ts new file mode 100644 index 0000000..f755c51 --- /dev/null +++ b/src/common/ai-html.util.ts @@ -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, '>'); +} + +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) => `
  • ${escapeText(item)}
  • `) + .join(''); + htmlParts.push(``); + continue; + } + + htmlParts.push(`

    ${escapeText(lines.join(' '))}

    `); + } + + return htmlParts.join('') || `

    ${escapeText(normalized)}

    `; +} + +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 += '
    '; + continue; + } + + const isClosing = token.value.startsWith('` : `<${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; +} diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts index 3e5e8ca..4ffa88d 100644 --- a/src/products/product-ai.service.ts +++ b/src/products/product-ai.service.ts @@ -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) : [];