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)}
/>;

Next steps​