VoterGuideOS
Concepts

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 summary for rich text and summaryPlainText for generated plain text.
  • Measures use whatItMeansHtml for rich text and whatItMeansPlainText for generated plain text. Despite its name, whatItMeansHtml contains Lexical JSON. The separate whatItMeans field 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 http and https.
  • 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.

On this page