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;
|
||||
}
|
||||
Reference in New Issue
Block a user