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:
| Lexicon | Field | Description |
|---|---|---|
pub.chive.richtext.defs | (shared) | Shared type definitions for all rich text |
pub.chive.eprint.submission | titleRich | Rich title with LaTeX and entity refs |
pub.chive.eprint.submission | abstract | Rich abstract with full formatting |
pub.chive.review.comment | body | Rich comment body |
pub.chive.annotation.comment | body | Rich 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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | RichTextItem[] | LegacyAnnotationItem[] | undefined | Rich text items to render |
| text | string | undefined | Plain text (for ATProto facet mode) |
| facets | AtprotoFacet[] | null | undefined | ATProto facets (used with text) |
| mode | 'inline' | 'block' | 'inline' | Rendering mode |
| className | string | undefined | Additional CSS classes |
| testId | string | '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.pubor/path): Renders as Next.jsLink - 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:
| Prop | Type | Default | Description |
|---|---|---|---|
| abstractItems | RichTextItem[] | (required) | Rich text content |
| maxLength | number | 300 | Character limit when collapsed |
| defaultExpanded | boolean | false | Initial expansion state |
| className | string | undefined | Additional 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:
| Subkind | Background | Text |
|---|---|---|
| field | purple-100 | purple-800 |
| institution | emerald-100 | emerald-800 |
| person | amber-100 | amber-800 |
| method | cyan-100 | cyan-800 |
| dataset | orange-100 | orange-800 |
| default | slate-100 | slate-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.tsxweb/components/eprints/eprint-abstract.test.tsxweb/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:
- Initial highlight: Uses PDF.js text layer selection
- Position refinement: Adjusts bounding rect to match text boundaries
- 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)}
/>;
Related Documentation
- Lexicons Reference: Complete schema documentation for rich text types
- Frontend Development: General frontend architecture
- Eprint Lifecycle Components: Edit, version, delete components
- ATProto Facets: ATProto rich text specification