feat: make detectCustomHtmlPatterns aware of TipTap's actual capabilities
The previous detection tripped on ANY inline `style=` attribute and on `<span>` elements specifically, which forced templates into HTML-only editing mode whenever the user had used Visual mode features like text color. TipTap's TextStyle + Color + Link extensions (configured in EmailEditor.tsx) natively round-trip exactly that markup -- TipTap emits `<span style="color: rgb(...)">…</span>` itself when you change a text color, then the detection rejected it as "custom HTML" on the very next load. Empirically about 21% of a 156-template corpus tripped this purely on TipTap-export artifacts (`background-color: initial`, color spans). This rewrite permits what TipTap can represent and rejects only what it can't: * Drop the broad inline-style check entirely (TextStyle/Color/Link preserve inline styles on spans and links). * Remove `<span>` from the custom-elements list (TextStyle handles it). * Expand the custom-elements list to explicitly cover everything TipTap has no extension for: `<div>`, `<section>`, `<article>`, `<header>`, `<footer>`, `<nav>`, `<aside>`, `<main>`, full table family (`<table>`, `<tr>`, `<td>`, `<th>`, `<tbody>`, `<thead>`, `<tfoot>`, `<colgroup>`, `<col>` -- no Table extension is loaded), form/embed/ media/interactive (`<form>`, `<input>`, `<button>`, `<select>`, `<textarea>`, `<iframe>`, `<video>`, `<audio>`, `<svg>`, `<object>`, `<embed>`, `<details>`, `<summary>`, `<dialog>`). Single-table is now enough to opt out (previously needed nested tables -- harmless tightening, single `<table>` already isn't TipTap content). * Tighten the custom-attributes regex with a leading `[\s"']` boundary so query strings like `<a href="…?id=…">` no longer false-match as an HTML `id=` attribute. * `<style>` tags, `@media` queries, and the class allowlist (`prose`, `variable-`, `email-image`, `ProseMirror`, `resizable-image`, `selected`, `resize-handle`) are unchanged. Mirrors the same logic in apps/api/src/services/EmailService.ts so the server-side wrap decision in `EmailService.compile()` stays in lockstep with the client-side editor-mode decision. Side-effect on `wrapEmailWithStyles` / `EmailService.compile`: templates that previously kept their own (unwrapped) shell because they contained a colored `<span>` or an inline-styled `<a>` will now flow through the prose wrapper. This is the correct behavior -- those templates ARE visual-editor output and SHOULD get the same wrapper the preview modal applies. Tests: new vitest suite at apps/web/src/lib/__tests__/emailStyles.test.ts covers 24 cases including the TipTap-export artifacts above, the href-URL-with-id false-match, and the rejected-element set.
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user