Rich Text Content Pipeline
How formatted text flows from editor to screen. This covers the complete data path across Application-Frontend (AF), Creative-Engine (CE), and Creative Composer (CC).
Two Storage Strategies
Rich text uses different storage formats depending on where the text lives:
| Context | Storage Format | Why |
|---|---|---|
| Visual blocks (text, button, tagline) | ProseMirror JSON in richText field | Structured data, future extensibility (animations, DCO tokens) |
| Flow operators (statement, answer, link, change-text) | HTML string in inputText.value | Backward compatible, no new fields needed |
Both paths coexist. Existing creatives without richText render via the plain text field as before.
Data Flow: Visual Blocks
AF Editor Storage (Backend) CE Rendering
----------- ---------------- ------------
Tiptap editor
|
v
editor.getJSON()
|
v
ProseMirror JSON --------> richText: { type: "doc", ... }
|
v
jsonToHtml(richText)
|
v
<span style="font-size: 24px">
<strong>bold</strong>
|
v
v-html="renderedContent"Key files
| Step | File | What it does |
|---|---|---|
| Editor | AF/src/components/common/RichTextEditor/RichTextEditor.vue | Tiptap wrapper, bubble menu, outputs JSON or HTML |
| Extension | AF/src/components/common/RichTextEditor/fontSizeExtension.ts | Adds fontSize, fontWeight, color to textStyle marks |
| Config | AF/.../Configuration/components/ContentSection.vue | Wires editor to configurationLogic.updateValue() |
| Converter | CE/src/utils/content/jsonToHtml.ts | ProseMirror JSON to HTML (no Tiptap dependency) |
| Text block | CE/src/components/creative/VisualElements/CreativeTextBlock.vue | Renders via v-html with jsonToHtml fallback |
| Button block | CE/src/components/creative/VisualElements/CreativeButtonBlock.vue | Same pattern as text block |
| Tagline | CE/src/components/conversationflow/atoms/Tagline.vue | Same pattern, reads from taglineProperties |
Fallback for old creatives
Every rendering component has the same fallback:
renderedContent() {
if (richText) {
return jsonToHtml(richText)
}
// Old creatives: escape plain text for safe v-html
return this.parsedText
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
}This produces identical visual output to Vue's text interpolation. The manual escaping is needed because v-html interprets HTML.
jsonToHtml supported marks
| ProseMirror mark | HTML output |
|---|---|
| bold | <strong>text</strong> |
| italic | <em>text</em> |
| underline | <u>text</u> |
| strike | <s>text</s> |
| textStyle | <span style="font-size: 24px">text</span> |
| textStyle | <span style="font-weight: 700">text</span> |
| textStyle | <span style="color: #ff0000">text</span> |
| textStyle | <span style="font-size: 24px; font-weight: 700; color: #ff0000">text</span> |
| textStyle { } (empty) | No wrapper (returns plain text) |
Text content is always HTML-escaped before marks are applied. Marks are applied outermost-first (first mark in the array wraps outermost).
Data Flow: Flow Operators
AF Editor Storage (Backend) CE Rendering
----------- ---------------- ------------
Tiptap editor
|
v
editor.getHTML()
|
v
stripEmptySpans()
|
v
HTML string --------------> inputText.value: "Hello <strong>world</strong>"
|
v
RichText.getRichText(text)
|
v
Sanitized HTML (whitelist)
|
v
v-html="innerHTML"Key files
| Step | File | What it does |
|---|---|---|
| Editor | AF/src/components/common/RichTextEditor/RichTextEditor.vue | mode="html", transparent, outputs HTML string |
| Operators | AF/.../CavaiFlow/operators/StatementOp.vue (and Answer, Link, ChangeText) | Passes HTML to RichTextEditor |
| Sanitizer | CE/src/utils/content/richText.ts | Whitelist-based HTML sanitizer |
| Text renderer | CE/src/components/blocks/basic/Text.vue | Flow text rendering via v-html |
| Choice renderer | CE/src/components/blocks/basic/Choice.vue | Flow choice rendering via v-html |
Sanitizer whitelist
The sanitizer uses a placeholder-and-escape strategy:
- Strip bare
<span>tags (Tiptap artifacts with no style attribute) - Replace allowed tags with null-byte placeholders
- Escape all remaining
<and>to</> - Restore placeholders with the original allowed tags
Allowed tags:
| Pattern | Example | Regex |
|---|---|---|
| Simple inline tags | <strong>, </em>, <u>, <s>, <p> | ^<\/?(strong|em|u|s|p)>$ |
| Styled spans | <span style="font-size: 16px"> | See below |
| Closing span | </span> | Only when paired with an allowed opener (counter-tracked) |
Styled span validation regex:
^<span style="(?:font-size: \d+(?:\.\d+)?(?:px|rem|em|%)(?:; ?)?|font-weight: \d{3}(?:; ?)?|color: (?:#[0-9a-fA-F]{6,8}|rgba?\(\d{1,3}, ?\d{1,3}, ?\d{1,3}(?:, ?\d?\.?\d+)?\))(?:; ?)?)+">$What this allows:
font-size: decimal numbers with px/rem/em/% unitsfont-weight: exactly 3-digit numbers (100-999)color: 6 or 8-digit hex (#rrggbb,#rrggbbaa),rgb(),rgba()- Multiple properties joined with
;(semicolon, optional space) - Properties in any order, any combination
What this rejects (escaped to </>):
- Named colors (
color: red) - CSS keywords (
color: inherit,color: currentColor) - Named font weights (
font-weight: bold) - CSS expressions (
font-size: expression(alert(1))) - Any other CSS property (
background,position, etc.) - Any attribute other than
style
Bare span stripping
Tiptap generates empty <span > wrappers when textStyle marks have no attributes (e.g., user applied then removed a font size). These are stripped before sanitization:
const BARE_SPAN_PATTERN = /<span\s*>([\s\S]*?)<\/span>/g
while (BARE_SPAN_PATTERN.test(cleaned)) {
cleaned = cleaned.replace(BARE_SPAN_PATTERN, '$1')
}The while loop handles nested bare spans. Non-greedy matching ensures innermost spans are stripped first.
Engine CSS for Rich Text
The engine blocks add explicit CSS rules for formatting tags:
styles() {
return {
// ... existing block styles ...
'& strong, & b': { fontWeight: '700' },
'& em, & i': { fontStyle: 'italic' },
}
}This ensures <strong> and <em> render correctly even when the parent block has an explicit fontWeight set. <u> and <s> use browser defaults (text-decoration) which aren't overridden by block styles.
CustomCSS overrides for rich text
When a block has explicit customCSS that sets text properties (color, font-size, font-weight), those properties must override inline rich text formatting. getRichTextOverrides() in richText.ts generates nested JSS selectors that force customCSS values onto span[style], strong, em, u, and s elements with !important.
This only applies to properties explicitly set in customCSS, not to the block's default style properties. CreativeTextBlock.vue parses customCSS directly and passes only those parsed properties to getRichTextOverrides(). When customCSS is empty, no overrides are generated and inline rich text colors render as-is.
Flow components (MessageHolder, Choice) do not use getRichTextOverrides -- inline rich text colors override the container's color naturally via CSS specificity.
Editor Architecture
RichTextEditor component
A shared Tiptap wrapper used in both visual block configs and flow operators.
Props:
| Prop | Type | Default | Purpose |
|---|---|---|---|
| mode | 'json' | 'html' | 'json' | Output format |
| transparent | boolean | false | Inherit font/bg from parent (for operators) |
| defaultFontSize | string | '' | Block's base font size (shown when no inline override) |
| defaultFontWeight | string/number | '' | Block's base font weight |
| defaultColor | string | '#000000' | Block's text color |
| fontFamily | string | '' | Filters available font weights |
| singleLine | boolean | false | Disable line breaks (for taglines, buttons) |
| placeholder | string | '' | Placeholder text |
Bubble menu tools:
- Italic (I), Underline (U), Strikethrough (S), Uppercase toggle (TT)
- Font size input (number)
- Font weight picker (dropdown: Light/Normal/Medium/Semi Bold/Bold)
- Color picker (hex only, no alpha)
Keyboard shortcuts:
Cmd+B-- Toggle bold (fontWeight 700 vs default)Cmd+S-- Toggle strikethrough (only with text selected)Cmd+Shift+U-- Toggle uppercase (transforms actual text, preserves marks)
Font size visual scaling in editor
Inline font sizes in the editor are visually scaled to show relative differences without breaking the compact layout. This uses ProseMirror decorations (editor-only, never affects stored data):
Actual size Editor class Visual scale
<= 10px .fs-xs 0.75em
11-14px .fs-sm 0.88em
15-20px .fs-md 1.00em
21-32px .fs-lg 1.15em
> 32px .fs-xl 1.35emThe decoration plugin in fontSizeExtension.ts walks the document on each transaction, finds text nodes with fontSize marks, and adds the appropriate CSS class. The classes only exist in the editor DOM.
Backward Compatibility
Why it's safe
Optional
richTextfield: All block types haverichText?: Record<string, unknown>. Old creatives without it use thetextfallback path.HTML in operators is backward compatible: Plain text is valid HTML. Old operator values like
"Hello world"render identically through the sanitizer.Sanitizer is strictly additive: It only ALLOWS specific tags. Everything else is escaped as before. No existing content can produce unexpected rendering.
No backend changes needed: Block properties and operator data are schemaless JSON. The
richTextfield and HTML formatting ininputText.valueare transparent to the API.
Trust boundaries
jsonToHtml()trusts ProseMirror JSON from Tiptap (our own editor validates marks)RichText.getRichText()trusts nothing -- strict whitelist regex on every tag- Text content is HTML-escaped in both paths
Test Coverage
| File | Tests | What's covered |
|---|---|---|
CE/tests/unit/richText.test.ts | 45 | Allowed tags, styled spans (font-size, font-weight, color), bare span stripping, disallowed tags, nesting, XSS |
CE/tests/unit/jsonToHtml.test.ts | 22 | All marks, textStyle with fontSize/fontWeight/color/combined, empty docs, escaping, nesting, paragraphs, hardBreak |