Skip to main content

Frontend Rich Text System

This guide covers the unified rich text rendering system for Chive's frontend.

Lexicon Schemas

The rich text system implements three lexicon schemas:

LexiconFieldDescription
pub.chive.richtext.defs(shared)Shared type definitions for all rich text
pub.chive.eprint.submissiontitleRichRich title with LaTeX and entity refs
pub.chive.eprint.submissionabstractRich abstract with full formatting
pub.chive.review.commentbodyRich comment body
pub.chive.annotation.commentbodyRich annotation body with formatting

See Lexicons Reference for complete schema documentation.

Overview

The rich text system provides consistent rendering of formatted content across titles, abstracts, reviews, and annotations. It combines:

  • ATProto-style facets (mentions, links, hashtags)
  • Entity references (knowledge graph nodes, Wikidata, fields, eprints)
  • Markdown formatting (bold, italic, strikethrough, code)
  • LaTeX math expressions (inline and display mode)

Architecture

Type Hierarchy

All rich text types are defined in web/lib/types/rich-text.ts:

RichTextItem (union type)
├── TextItem # Plain text with optional formatting
├── MentionItem # @handle ATProto mentions
├── LinkItem # URLs (internal and external)
├── TagItem # #hashtags
├── NodeRefItem # Knowledge graph node references
├── WikidataRefItem # Wikidata entity references (QID)
├── FieldRefItem # Academic field references
├── FacetRefItem # Facet classification references
├── EprintRefItem # Eprint references
├── AnnotationRefItem # Annotation references
├── AuthorRefItem # Author references (DID)
├── LatexItem # LaTeX math expressions
└── CodeItem # Code blocks and inline code

Data Flow

Record from PDS              RichTextRenderer
│ │
▼ ▼
Legacy format? ──yes──► fromLegacyAnnotationItems()
│ │
no │
│ ▼
▼ RichTextItem[]
ATProto facets? ─yes──► fromAtprotoRichText()
│ │
no │
│ ▼
▼ ItemRenderer loop
RichTextItem[] ────────────────► │

Rendered output

RichTextRenderer Component

The main rendering component is located at web/components/editor/rich-text-renderer.tsx.

Basic Usage

import { RichTextRenderer } from '@/components/editor/rich-text-renderer';
import type { RichTextItem } from '@/lib/types/rich-text';

const items: RichTextItem[] = [
{ type: 'text', content: 'Research on ' },
{ type: 'nodeRef', uri: 'at://...', label: 'machine learning', subkind: 'field' },
{ type: 'text', content: ' by ' },
{ type: 'mention', did: 'did:plc:abc123', handle: 'alice.bsky.social' },
];

<RichTextRenderer items={items} mode="inline" />;

Props

PropTypeDefaultDescription
itemsRichTextItem[] | LegacyAnnotationItem[]undefinedRich text items to render
textstringundefinedPlain text (for ATProto facet mode)
facetsAtprotoFacet[] | nullundefinedATProto facets (used with text)
mode'inline' | 'block''inline'Rendering mode
classNamestringundefinedAdditional CSS classes
testIdstring'rich-text'data-testid attribute

Input Formats

The component accepts three input formats:

1. Item-based format (recommended):

<RichTextRenderer
items={[
{ type: 'text', content: 'Hello ' },
{ type: 'mention', did: 'did:plc:abc', handle: 'alice' },
]}
/>

2. ATProto text+facets format:

<RichTextRenderer
text="Hello @alice.bsky.social!"
facets={[
{
index: { byteStart: 6, byteEnd: 25 },
features: [{ $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:abc' }],
},
]}
/>

3. Legacy annotation format (auto-converted):

<RichTextRenderer
items={[
{ type: 'text', content: 'See ' },
{ type: 'wikidataRef', qid: 'Q123', label: 'example' },
]}
/>

Render Modes

Inline mode (default): Items flow inline. Use for titles and short text.

<RichTextRenderer items={items} mode="inline" />

Block mode: Preserves whitespace and line breaks. Use for abstracts and long content.

<RichTextRenderer items={items} mode="block" />

Supported Item Types

TextItem

Plain text with optional formatting.

interface TextItem {
type: 'text';
content: string;
format?: {
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
code?: boolean;
};
}

Formats can be combined:

{
type: 'text',
content: 'important',
format: { bold: true, italic: true }
}
// Renders: <strong><em>important</em></strong>

MentionItem

ATProto @handle references. Links to author profile page.

interface MentionItem {
type: 'mention';
did: string;
handle?: string;
displayName?: string;
}

Renders as a blue link: @alice.bsky.social

LinkItem

URLs with automatic handling for internal and external links.

interface LinkItem {
type: 'link';
url: string;
label?: string;
}

Behavior by URL type:

  • Internal links (chive.pub or /path): Renders as Next.js Link
  • Wikidata links: Renders as a badge with external icon
  • Other external links: Opens in new tab with external icon

URL validation and normalization:

  • URLs are validated before rendering to prevent XSS attacks
  • Relative URLs are resolved against the current page
  • Protocol-relative URLs (//example.com) are normalized to HTTPS
  • Invalid URLs display as plain text with error styling

TagItem

Hashtag references. Links to search results.

interface TagItem {
type: 'tag';
tag: string; // without the # prefix
}

Renders as: #machine-learning

NodeRefItem

Knowledge graph node references. Displayed as colored badges based on subkind.

interface NodeRefItem {
type: 'nodeRef';
uri: string;
label: string;
subkind?: string; // 'field', 'institution', 'person', 'method', etc.
}

Badge colors are determined by getSubkindColorClasses() from @/lib/constants/subkind-colors.

WikidataRefItem

Wikidata entity references. Displayed as blue badges with external link icon.

interface WikidataRefItem {
type: 'wikidataRef';
qid: string; // e.g., 'Q123456'
label: string;
url?: string; // optional URL override
}

FieldRefItem

Academic field references. Uses field-specific styling.

interface FieldRefItem {
type: 'fieldRef';
uri: string;
label: string;
}

FacetRefItem

PMEST facet classification references. Links to browse page with filter.

interface FacetRefItem {
type: 'facetRef';
dimension: string; // 'time', 'space', 'energy', 'matter', 'personality'
value: string;
}

EprintRefItem

Eprint references. Links to eprint detail page.

interface EprintRefItem {
type: 'eprintRef';
uri: string;
title: string;
}

AnnotationRefItem

Annotation excerpt references.

interface AnnotationRefItem {
type: 'annotationRef';
uri: string;
excerpt: string;
}

AuthorRefItem

Author references by DID.

interface AuthorRefItem {
type: 'authorRef';
did: string;
displayName?: string;
handle?: string;
}

LatexItem

LaTeX math expressions rendered via KaTeX.

interface LatexItem {
type: 'latex';
content: string; // LaTeX source (without delimiters)
displayMode: boolean; // true for block, false for inline
}

Examples:

// Inline: renders as part of text flow
{ type: 'latex', content: '\\alpha + \\beta', displayMode: false }

// Display: renders as centered block
{ type: 'latex', content: '\\int_0^\\infty e^{-x} dx = 1', displayMode: true }

CodeItem

Code blocks and inline code.

interface CodeItem {
type: 'code';
content: string;
language?: string; // for syntax highlighting
block?: boolean; // true for code block, false for inline
}

Integration with Eprint Components

EprintAbstract

The EprintAbstract component in web/components/eprints/eprint-abstract.tsx uses RichTextRenderer for abstract display with expand/collapse functionality.

import { EprintAbstract } from '@/components/eprints/eprint-abstract';

<EprintAbstract abstractItems={eprint.abstractItems} maxLength={300} defaultExpanded={false} />;

Props:

PropTypeDefaultDescription
abstractItemsRichTextItem[](required)Rich text content
maxLengthnumber300Character limit when collapsed
defaultExpandedbooleanfalseInitial expansion state
classNamestringundefinedAdditional CSS classes

StaticAbstract

For list views where expansion is not needed:

import { StaticAbstract } from '@/components/eprints/eprint-abstract';

<StaticAbstract abstractItems={eprint.abstractItems} maxLines={3} />;

Schema Migration Utilities

When working with records that may use older field formats, use the migration utilities.

Detecting Migration Needs

Located in web/lib/api/schema-migration.ts:

import { needsSchemaMigration, detectFieldsNeedingMigration } from '@/lib/api/schema-migration';

if (needsSchemaMigration(record)) {
const fields = detectFieldsNeedingMigration(record);
console.log('Fields to migrate:', fields); // ['title', 'abstract', 'license']
}

Migrating Records

import { transformToCurrentSchema } from '@/lib/api/schema-migration';

const result = transformToCurrentSchema(record);

if (result.success && result.record) {
// result.record contains the migrated record
console.log('Migrated fields:', result.fields);
}

Field-Specific Migrations

Abstract migration: Converts plain string to RichTextBodyItem array.

import { migrateAbstractToRichText } from '@/lib/api/schema-migration';

const richAbstract = migrateAbstractToRichText('Plain text abstract');
// Returns: [{ type: 'text', content: 'Plain text abstract' }]

Title migration: Parses LaTeX from plain title into rich text items.

import { migrateTitleToRichText, isLegacyTitleFormat } from '@/lib/api/schema-migration';

if (isLegacyTitleFormat(record.title, record.titleRich)) {
const richTitle = migrateTitleToRichText(record.title);
// Separates LaTeX into dedicated items
}

License migration: Adds knowledge graph URI to license slug.

import { migrateLicenseToNode } from '@/lib/api/schema-migration';

const license = migrateLicenseToNode('CC-BY-4.0');
// Returns: {
// licenseSlug: 'CC-BY-4.0',
// licenseUri: 'at://did:plc:chive-governance/pub.chive.graph.node/fc58b045-e186-5081-b7eb-abc5c47ea8a3'
// }

useSchemaMigration Hook

Located in web/lib/hooks/use-schema-migration.ts.

Handles the complete migration flow: fetch, transform, and update.

import { useSchemaMigration, canUserMigrateRecord } from '@/lib/hooks/use-schema-migration';

function MigrationButton({ eprint, currentUserDid }) {
const { mutateAsync: migrateRecord, isPending, error } = useSchemaMigration();

const canMigrate = canUserMigrateRecord(eprint.uri, eprint.submittedBy, currentUserDid);

const handleMigrate = async () => {
try {
const result = await migrateRecord({ uri: eprint.uri });
toast.success(`Migrated ${result.fields.length} fields`);
} catch (err) {
if (err instanceof SchemaMigrationError) {
toast.error(`Migration failed in ${err.phase} phase: ${err.message}`);
}
}
};

if (!canMigrate) return null;

return (
<Button onClick={handleMigrate} disabled={isPending}>
{isPending ? 'Updating...' : 'Update to Latest Format'}
</Button>
);
}

SchemaMigrationError

Error class with phase information:

class SchemaMigrationError extends Error {
readonly code = 'SCHEMA_MIGRATION_ERROR';
readonly uri: string;
readonly phase: 'fetch' | 'transform' | 'update';
}

Utility Functions

Plain Text Extraction

Extract plain text from rich text items (for search, truncation):

import { extractPlainText } from '@/lib/types/rich-text';

const plainText = extractPlainText(items);

Creating Rich Text

import { createFromPlainText, createEmptyRichText } from '@/lib/types/rich-text';

// From plain string
const richText = createFromPlainText('Hello world');

// Empty structure
const empty = createEmptyRichText();

Type Guards

import {
isTextItem,
isMentionItem,
isLinkItem,
isEntityRefItem,
isLatexItem,
} from '@/lib/types/rich-text';

if (isLatexItem(item)) {
console.log('LaTeX:', item.content);
}

if (isEntityRefItem(item)) {
// item is NodeRefItem | WikidataRefItem | FieldRefItem | ...
}

Legacy Format Conversion

import { fromLegacyAnnotationItems, toLegacyAnnotationItems } from '@/lib/types/rich-text';

// Convert legacy to unified format
const unified = fromLegacyAnnotationItems(legacyItems);

// Convert back (for backward compatibility)
const legacy = toLegacyAnnotationItems(unifiedItems);

ATProto Facet Conversion

import { fromAtprotoRichText } from '@/lib/types/rich-text';

const items = fromAtprotoRichText(text, facets);

Note: ATProto facets use byte indices (UTF-8), which the function automatically converts to JavaScript string indices (UTF-16).

Styling

Badge Colors

Node reference badges use subkind-specific colors defined in @/lib/constants/subkind-colors:

SubkindBackgroundText
fieldpurple-100purple-800
institutionemerald-100emerald-800
personamber-100amber-800
methodcyan-100cyan-800
datasetorange-100orange-800
defaultslate-100slate-800

CSS Classes

The renderer applies these base classes:

/* Container */
.leading-relaxed [&>*]:inline [&_.badge]:mx-0.5 [&_.badge]:align-baseline

/* Block mode adds */
.whitespace-pre-wrap

/* Links */
.text-blue-600 hover:underline dark:text-blue-400

/* Inline code */
.rounded bg-muted px-1 py-0.5 font-mono text-sm

/* Code blocks */
.rounded bg-muted p-3 overflow-x-auto my-2

Testing

Test files:

  • web/components/editor/rich-text-renderer.test.tsx
  • web/components/eprints/eprint-abstract.test.tsx
  • web/lib/types/rich-text.test.ts

Run with:

pnpm test:unit web/components/editor
pnpm test:unit web/components/eprints/eprint-abstract
pnpm test:unit web/lib/types/rich-text

Annotation system

The annotation system extends rich text with W3C Web Annotation targets for positioning highlights on PDF text.

Chive distinguishes two types of text-linked content:

  • Inline annotations (pub.chive.annotation.comment): Comments on specific text spans, with required W3C targets
  • Document-level reviews (pub.chive.review.comment): Broader feedback on the whole eprint, with optional targets

W3C Web Annotation targets

Annotations use W3C selectors to identify text spans:

interface TextSpanTarget {
versionUri?: string; // AT-URI of specific eprint version
selector: TextQuoteSelector | TextPositionSelector | FragmentSelector;
refinedBy?: PositionRefinement; // Page number and bounding box
}

The primary selector is TextQuoteSelector, which matches text by exact content with optional context:

interface TextQuoteSelector {
type: 'TextQuoteSelector';
exact: string; // Exact text to match, max 1000 chars
prefix?: string; // Context before, max 100 chars
suffix?: string; // Context after, max 100 chars
}

Bounding rectangle

PDF annotations include bounding rectangle data to position highlights. Coordinates are stored as strings to preserve floating-point precision (ATProto only supports integer types):

interface BoundingRect {
x1: string;
y1: string;
x2: string;
y2: string;
width: string;
height: string;
pageNumber: number;
}

Position refinement

The annotation system includes position refinement to improve highlight accuracy:

  1. Initial highlight: Uses PDF.js text layer selection
  2. Position refinement: Adjusts bounding rect to match text boundaries
  3. Serialization: Stores coordinates as strings for ATProto compatibility

Selection tip

The PDF viewer shows a selection tip on the first highlight to guide users. The tip appears immediately when a text selection is made and can be dismissed.

Document location card

The DocumentLocationCard component shows annotation context:

import { DocumentLocationCard } from '@/components/reviews/document-location-card';

<DocumentLocationCard
pageNumber={annotation.target?.refinedBy?.pageNumber}
excerpt={annotation.target?.selector?.exact}
onNavigate={() => scrollToAnnotation(annotation)}
/>;