Render editorial rich text
Render candidate and measure editorial summaries returned as serialized Lexical editor state.
Branch editorial rich text is not HTML. It is a JSON string containing a Lexical editor state. Parse the JSON and render its node tree instead of inserting the value into the DOM as HTML.
API fields
- Candidates use
summaryfor rich text andsummaryPlainTextfor generated plain text. - Measures use
whatItMeansHtmlfor rich text andwhatItMeansPlainTextfor generated plain text. Despite its name,whatItMeansHtmlcontains Lexical JSON. The separatewhatItMeansfield is maintained independently and is not overwritten when rich text changes.
The API may return a legacy plain string in either rich-text field. If JSON parsing fails, display the original value as plain text.
Generated plain text removes visual formatting but does preserve any in-line links.
Renderer example (React)
The following dependency-free renderer is adapted from the Branch voter guide's
RichTextDisplay component. It supports the nodes and formatting currently
produced by the client portal, falls back to plain text, and recursively renders
the children of unknown nodes for forward compatibility.
import React from 'react';
type RichNode = {
type?: string;
text?: string;
format?: number;
level?: number;
listType?: 'bullet' | 'number';
tag?: string;
url?: string;
children?: RichNode[];
};
type EditorState = {
root?: {
children?: RichNode[];
};
};
const BOLD = 1;
const ITALIC = 2;
const UNDERLINE = 8;
const CODE = 16;
function safeLinkUrl(value?: string) {
if (!value) return undefined;
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.toString() : undefined;
} catch {
return undefined;
}
}
function InlineNode({ node }: { node: RichNode }) {
if (typeof node.text === 'string') {
let content: React.ReactNode = node.text;
const format = node.format ?? 0;
if (format & BOLD) content = <strong>{content}</strong>;
if (format & ITALIC) content = <em>{content}</em>;
if (format & UNDERLINE) content = <u>{content}</u>;
if (format & CODE) content = <code>{content}</code>;
return content;
}
const children = node.children?.map((child, index) => (
<InlineNode key={index} node={child} />
));
if (node.type === 'link' || node.type === 'autolink') {
const href = safeLinkUrl(node.url);
return href ? (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
) : children;
}
return children;
}
function BlockNode({ node }: { node: RichNode }) {
const inlineChildren = node.children?.map((child, index) => (
<InlineNode key={index} node={child} />
));
switch (node.type) {
case 'paragraph':
return <p>{inlineChildren}</p>;
case 'heading': {
const level = Math.min(Math.max(node.level ?? 2, 1), 6);
return React.createElement(`h${level}`, null, inlineChildren);
}
case 'list': {
const ordered = node.listType === 'number' || node.tag === 'ol';
const List = ordered ? 'ol' : 'ul';
return (
<List>
{node.children?.map((item, index) => (
<li key={index}>
{item.children?.map((child, childIndex) => (
<InlineNode key={childIndex} node={child} />
))}
</li>
))}
</List>
);
}
default:
return (
<>
{node.children?.map((child, index) => (
<BlockNode key={index} node={child} />
))}
</>
);
}
}
export function EditorialRichText({ content }: { content?: string }) {
if (!content) return null;
let editorState: EditorState;
try {
editorState = JSON.parse(content);
} catch {
return <p>{content}</p>;
}
if (!Array.isArray(editorState.root?.children)) {
return <p>{content}</p>;
}
return (
<>
{editorState.root.children.map((node, index) => (
<BlockNode key={index} node={node} />
))}
</>
);
}Use the component with the rich field from either resource:
<EditorialRichText content={candidate.summary} />
<EditorialRichText content={measure.whatItMeansHtml} />If formatting and clickable links are unnecessary, render the corresponding generated plain-text field directly.
Rendering requirements
- Do not pass the stored string to
dangerouslySetInnerHTML; it is JSON, not sanitized HTML. - Allow only URL protocols your application supports. The example permits
httpandhttps. - Use
rel="noopener noreferrer"when opening links in a new tab. - Recursively render children of unknown nodes so new Lexical node types do not silently hide their text.
- Treat text-format values as bitmasks. A node can be bold and underlined at the same time.
