Skip to content

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:

ContextStorage FormatWhy
Visual blocks (text, button, tagline)ProseMirror JSON in richText fieldStructured data, future extensibility (animations, DCO tokens)
Flow operators (statement, answer, link, change-text)HTML string in inputText.valueBackward 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

StepFileWhat it does
EditorAF/src/components/common/RichTextEditor/RichTextEditor.vueTiptap wrapper, bubble menu, outputs JSON or HTML
ExtensionAF/src/components/common/RichTextEditor/fontSizeExtension.tsAdds fontSize, fontWeight, color to textStyle marks
ConfigAF/.../Configuration/components/ContentSection.vueWires editor to configurationLogic.updateValue()
ConverterCE/src/utils/content/jsonToHtml.tsProseMirror JSON to HTML (no Tiptap dependency)
Text blockCE/src/components/creative/VisualElements/CreativeTextBlock.vueRenders via v-html with jsonToHtml fallback
Button blockCE/src/components/creative/VisualElements/CreativeButtonBlock.vueSame pattern as text block
TaglineCE/src/components/conversationflow/atoms/Tagline.vueSame pattern, reads from taglineProperties

Fallback for old creatives

Every rendering component has the same fallback:

ts
renderedContent() {
  if (richText) {
    return jsonToHtml(richText)
  }

  // Old creatives: escape plain text for safe v-html
  return this.parsedText
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

This produces identical visual output to Vue's text interpolation. The manual escaping is needed because v-html interprets HTML.

jsonToHtml supported marks

ProseMirror markHTML 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

StepFileWhat it does
EditorAF/src/components/common/RichTextEditor/RichTextEditor.vuemode="html", transparent, outputs HTML string
OperatorsAF/.../CavaiFlow/operators/StatementOp.vue (and Answer, Link, ChangeText)Passes HTML to RichTextEditor
SanitizerCE/src/utils/content/richText.tsWhitelist-based HTML sanitizer
Text rendererCE/src/components/blocks/basic/Text.vueFlow text rendering via v-html
Choice rendererCE/src/components/blocks/basic/Choice.vueFlow choice rendering via v-html

Sanitizer whitelist

The sanitizer uses a placeholder-and-escape strategy:

  1. Strip bare <span> tags (Tiptap artifacts with no style attribute)
  2. Replace allowed tags with null-byte placeholders
  3. Escape all remaining < and > to &lt;/&gt;
  4. Restore placeholders with the original allowed tags

Allowed tags:

PatternExampleRegex
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/% units
  • font-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 &lt;/&gt;):

  • 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:

ts
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:

ts
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:

PropTypeDefaultPurpose
mode'json' | 'html''json'Output format
transparentbooleanfalseInherit font/bg from parent (for operators)
defaultFontSizestring''Block's base font size (shown when no inline override)
defaultFontWeightstring/number''Block's base font weight
defaultColorstring'#000000'Block's text color
fontFamilystring''Filters available font weights
singleLinebooleanfalseDisable line breaks (for taglines, buttons)
placeholderstring''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.35em

The 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

  1. Optional richText field: All block types have richText?: Record<string, unknown>. Old creatives without it use the text fallback path.

  2. HTML in operators is backward compatible: Plain text is valid HTML. Old operator values like "Hello world" render identically through the sanitizer.

  3. Sanitizer is strictly additive: It only ALLOWS specific tags. Everything else is escaped as before. No existing content can produce unexpected rendering.

  4. No backend changes needed: Block properties and operator data are schemaless JSON. The richText field and HTML formatting in inputText.value are 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

FileTestsWhat's covered
CE/tests/unit/richText.test.ts45Allowed tags, styled spans (font-size, font-weight, color), bare span stripping, disallowed tags, nesting, XSS
CE/tests/unit/jsonToHtml.test.ts22All marks, textStyle with fontSize/fontWeight/color/combined, empty docs, escaping, nesting, paragraphs, hardBreak

Internal documentation