Merge pull request #384 from jaschaio/tiptap-aware-html-detection
feat: make detectCustomHtmlPatterns aware of TipTap's actual capabilities
This commit is contained in:
@@ -659,12 +659,16 @@ export class EmailService {
|
||||
/**
|
||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||
*
|
||||
* The TipTap editor loads StarterKit + TextAlign + Color + TextStyle + Link +
|
||||
* ResizableImage + VariableMention. TextStyle/Color/Link round-trip <span style="..."> and
|
||||
* <a style="..."> markup. This detection therefore PERMITS span + inline styles and only
|
||||
* REJECTS markup TipTap cannot represent (tables, divs, forms, embeds, custom attrs,
|
||||
* <style> blocks, etc).
|
||||
*/
|
||||
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
@@ -679,21 +683,21 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
// Element-attribute-scoped regex; the leading [\s"'] guard prevents `id=` inside
|
||||
// href URLs (e.g. `?id=...`) from false-matching as an HTML id attribute.
|
||||
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
|
||||
|
||||
// Elements TipTap cannot round-trip with the currently-loaded extension set.
|
||||
// <span> is intentionally excluded -- TipTap's TextStyle extension handles it.
|
||||
const hasCustomElements =
|
||||
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
|
||||
html,
|
||||
);
|
||||
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {detectCustomHtmlPatterns} from '../emailStyles';
|
||||
|
||||
describe('detectCustomHtmlPatterns', () => {
|
||||
describe('empty / whitespace input', () => {
|
||||
it('returns false for empty string', () => {
|
||||
expect(detectCustomHtmlPatterns('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only string', () => {
|
||||
expect(detectCustomHtmlPatterns(' \n\t ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content TipTap can round-trip (should NOT be flagged as custom)', () => {
|
||||
it('returns false for a basic paragraph', () => {
|
||||
expect(detectCustomHtmlPatterns('<p>Hello</p>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for headings, lists, blockquote, bold, italic', () => {
|
||||
expect(
|
||||
detectCustomHtmlPatterns(
|
||||
'<h1>Title</h1><p><strong>bold</strong> <em>italic</em></p><ul><li>one</li></ul><blockquote>quote</blockquote>',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <span> with inline color style (TipTap TextStyle output)', () => {
|
||||
expect(detectCustomHtmlPatterns('<span style="color: rgb(220, 38, 38)">red</span>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <span> with background-color: initial (TipTap export artifact)', () => {
|
||||
expect(detectCustomHtmlPatterns('<span style="background-color: initial">stuff</span>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <span> with background-color: transparent (TipTap export artifact)', () => {
|
||||
expect(detectCustomHtmlPatterns('<span style="background-color: transparent">stuff</span>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for <a> with inline style (TipTap Link output)', () => {
|
||||
expect(detectCustomHtmlPatterns('<a href="https://example.com" style="color: red">link</a>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for paragraph with inline text-align style', () => {
|
||||
expect(detectCustomHtmlPatterns('<p style="text-align: center">centered</p>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when an href URL contains "id=" or "contactId=" (must not match custom-attr regex)', () => {
|
||||
expect(
|
||||
detectCustomHtmlPatterns('<a href="https://example.com/u?contactId=abc&id=123">unsub</a>'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for allowed class prefixes', () => {
|
||||
expect(detectCustomHtmlPatterns('<p class="prose">x</p>')).toBe(false);
|
||||
expect(detectCustomHtmlPatterns('<span class="variable-mention">x</span>')).toBe(false);
|
||||
expect(detectCustomHtmlPatterns('<img class="email-image" src="x" />')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a TipTap-style colored span wrapped in a paragraph', () => {
|
||||
expect(
|
||||
detectCustomHtmlPatterns('<p>Hello <span style="color: rgb(220, 38, 38);">world</span>!</p>'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content TipTap can NOT round-trip (should be flagged as custom)', () => {
|
||||
it('returns true for <div>', () => {
|
||||
expect(detectCustomHtmlPatterns('<div>stuff</div>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <table> markup (no TipTap Table extension loaded)', () => {
|
||||
expect(detectCustomHtmlPatterns('<table><tr><td>x</td></tr></table>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a single <table> tag', () => {
|
||||
expect(detectCustomHtmlPatterns('<table>x</table>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <style> tag', () => {
|
||||
expect(detectCustomHtmlPatterns('<style>p { color: red; }</style>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for @media query inside a style block', () => {
|
||||
expect(detectCustomHtmlPatterns('@media (max-width: 600px) { ... }')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for custom data-* attribute', () => {
|
||||
expect(detectCustomHtmlPatterns('<p data-foo="bar">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for aria-* attribute', () => {
|
||||
expect(detectCustomHtmlPatterns('<p aria-label="x">y</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for role= attribute', () => {
|
||||
expect(detectCustomHtmlPatterns('<p role="presentation">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for id= attribute on an element', () => {
|
||||
expect(detectCustomHtmlPatterns('<p id="main">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a disallowed CSS class', () => {
|
||||
expect(detectCustomHtmlPatterns('<p class="custom">x</p>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <section>, <article>, <header>, <footer>, <nav>, <aside>, <main>', () => {
|
||||
expect(detectCustomHtmlPatterns('<section>x</section>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<article>x</article>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<header>x</header>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<footer>x</footer>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<nav>x</nav>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<aside>x</aside>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<main>x</main>')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for form/input/button/iframe/svg', () => {
|
||||
expect(detectCustomHtmlPatterns('<form>x</form>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<input type="text" />')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<button>x</button>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<iframe src="x"></iframe>')).toBe(true);
|
||||
expect(detectCustomHtmlPatterns('<svg><circle /></svg>')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
//
|
||||
// The TipTap editor in EmailEditor.tsx loads: StarterKit (paragraphs, headings, lists,
|
||||
// blockquote, code, hr, bold, italic, strike, etc.), TextAlign, Color, TextStyle, Link,
|
||||
// ResizableImage, and VariableMention. Of these, TextStyle + Color + Link natively
|
||||
// round-trip <span style="color: ..."> / <a style="color: ..."> markup that TipTap itself
|
||||
// generates when you change text color or style a link. We must therefore PERMIT what
|
||||
// TipTap can represent and REJECT only what it can't.
|
||||
export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
@@ -28,21 +33,26 @@ export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
// Custom attributes that carry semantics TipTap doesn't preserve. We require an
|
||||
// attribute-boundary (whitespace, `=`, or quote) before the prefix so that query
|
||||
// strings like `?id=...` inside an `href="..."` value don't false-match.
|
||||
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
|
||||
|
||||
// Elements TipTap cannot round-trip with the currently-loaded extension set.
|
||||
// - No Table/TableRow/TableCell extensions are loaded -> all table markup is custom.
|
||||
// - No Div/Section/etc. block-layout extensions -> reject layout containers.
|
||||
// - Form/embed/media/interactive elements have no TipTap representation here.
|
||||
// <span> is intentionally NOT in this list: TipTap's TextStyle extension emits and
|
||||
// accepts <span style="..."> for things like text color.
|
||||
const hasCustomElements =
|
||||
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
|
||||
html,
|
||||
);
|
||||
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||
};
|
||||
|
||||
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
|
||||
Reference in New Issue
Block a user