Skip to content

RelationalText Hub Format ​

The RelationalText hub (org.relationaltext.facet) is the canonical interchange format that all other formats convert through. It defines the common vocabulary of blocks, marks, and entities that survive round-trips across all 37+ supported formats.

Namespaces ​

All feature types in RelationalText fall into one of three namespace categories:

NamespacePurpose
org.relationaltext.richtext.*Programmatic API — used when constructing documents with Document.addMark(), Document.addBlock()
org.relationaltext.facet#*Hub lens targets — what importers produce and what autoTransform() routes through
Format-specific namespaces (e.g. org.commonmark.facet#*, app.bsky.richtext.facet#*)Each format's own feature identifiers, before or after lens conversion

Both org.relationaltext.richtext.* and org.relationaltext.facet#* are registered by ensureRelationalTextLexicon() (called automatically from core.ts). Lenses always produce org.relationaltext.facet#* compound keys; the richtext.mark/block namespace is what you get when constructing documents from code.

Functions ​

ts
import {
  ensureRelationalTextLexicon,
  registerFeatureType,
  Document,
} from 'relational-text/core'

ensureRelationalTextLexicon(): void ​

Register all org.relationaltext.facet#* and org.relationaltext.richtext.*#* types with the WASM lexicon. Called automatically when importing relational-text/core. Safe to call multiple times — subsequent calls are no-ops.

registerFeatureType(descriptor: FeatureTypeDescriptor): void ​

Register a single custom feature type at runtime. Use this to extend the hub with application-specific types.

ts
registerFeatureType({
  typeId: 'com.example.footnote',
  featureClass: 'inline',
  expandStart: false,
  expandEnd: false,
})

Feature Inventory ​

Block Elements ​

Blocks use 1-character markers in the text string (\uFFFC for the first block, \n for subsequent ones). Nesting is expressed with the parents array (Kleppmann model).

Feature name$type compound keyattrsNotes
paragraphorg.relationaltext.facet#paragraph—Default block type
headingorg.relationaltext.facet#heading{ level: 1–6 }
code-blockorg.relationaltext.facet#code-block{ language?: string }
horizontal-ruleorg.relationaltext.facet#horizontal-rule—Thematic break
blockquote-markerorg.relationaltext.facet#blockquote-marker—Used in parents[] for nesting
bullet-list-markerorg.relationaltext.facet#bullet-list-marker—List container marker
ordered-list-markerorg.relationaltext.facet#ordered-list-marker{ start?: number }List container marker
list-item-markerorg.relationaltext.facet#list-item-marker—
list-item-textorg.relationaltext.facet#list-item-text—Content block within a list item
tableorg.relationaltext.facet#table{ headers: string[], rows: string[][] }
embedorg.relationaltext.facet#embed{ src: string }Dropped when converting to CommonMark
page-breakorg.relationaltext.facet#page-break—Maps to horizontal-rule in CommonMark
detailsorg.relationaltext.facet#details—Collapsible section; maps to blockquote-marker in CommonMark

Inline Marks — Expand Both Sides (Peritext) ​

These marks grow when text is inserted at either boundary (expandStart: true, expandEnd: true).

Feature name$type compound keyattrs
boldorg.relationaltext.facet#bold—
italicorg.relationaltext.facet#italic—
underlineorg.relationaltext.facet#underline—
strikethroughorg.relationaltext.facet#strikethrough—
highlightorg.relationaltext.facet#highlight—
insertionorg.relationaltext.facet#insertion{ author?: string, date?: string }

Inline Marks — Expand Neither Side (Peritext) ​

These marks do not grow when text is inserted at their boundaries (expandStart: false, expandEnd: false).

Feature name$type compound keyattrs
codeorg.relationaltext.facet#code—
keyboardorg.relationaltext.facet#keyboard—
superscriptorg.relationaltext.facet#superscript—
subscriptorg.relationaltext.facet#subscript—
deletionorg.relationaltext.facet#deletion{ author?: string, date?: string }
commentorg.relationaltext.facet#comment{ id?: string }

Entity Types ​

Entities occupy a byte range and do not expand. They are rendered as self-contained elements rather than wrapping spans.

Feature name$type compound keyattrsNotes
linkorg.relationaltext.facet#link{ url: string, title?: string }url (not uri); lenses rename as needed
imageorg.relationaltext.facet#image{ src: string, alt?: string, title?: string }
mentionorg.relationaltext.facet#mention{ handle: string }Social @mention
hashtagorg.relationaltext.facet#hashtag{ tag: string }Social #hashtag
line-breakorg.relationaltext.facet#line-break—Hard line break

The Lens Graph ​

Without a hub, N formats require N² lenses. With a hub: just N lenses, each connecting one format to the center.

All 37 registered formats connect to the hub via X-to-relationaltext.lens.json files. The lens graph resolves multi-hop paths automatically — a document parsed from Quill can be exported to Apple News without any intermediate step in user code.

Quill      ─┐
TipTap     ─┤
ProseMirror─┤
Lexical    ─┤                         ┌─ HTML
Slate      ─┤                         ├─ CommonMark / GFM
Discord    ─┼─→  org.relationaltext  ─┤─ Apple News
Slack      ─┤         .facet          ├─ Bluesky (via filter_facets_by_namespace)
Mastodon   ─┤                         └─ (any format with a relationaltext-to-X lens)
Confluence ─┤
...        ─┘

The path from a source format to an output format may span multiple hops: e.g., org.commonmark.facet → org.relationaltext.facet → org.w3c.html.facet. The lens graph resolves these automatically inside autoTransform().

The autoTransform() Fast-Path ​

Each to() renderer calls lensGraph.autoTransform(doc, targetNamespace)before rendering. If the document's features are already intargetNamespace— or already inorg.relationaltext.facetwith a lens registered to the target — the graph skips intermediate steps. If a document already speaksorg.relationaltext.facet`, no lens is applied; it renders directly.

The autoTransform fast-path also short-circuits namespaces: if a document contains no features from a source namespace, passthrough-drop lenses for that namespace are skipped entirely, preventing them from wiping cross-format features.

Cross-Format Conversion ​

Via the from / to registry ​

ts
import { from, to } from 'relational-text'

// Any format → any other format
const doc = from('quill', quillDeltaJson)
const md  = to('markdown', doc)
const html = to('html', doc)

Via individual importers and exporters ​

ts
import { from, to } from 'relational-text/registry'

const doc  = from('slack', '*bold* and ~strikethrough~')
const html = to('html', doc)
// '<p><strong>bold</strong> and <s>strikethrough</s></p>\n'

Explicit hub conversion ​

ts
import { from } from 'relational-text/registry'
import { applyLens, lensGraph }         from 'relational-text/lens'

const doc = from('tiptap', tipTapJson)

// Inspect features in the org.relationaltext.facet namespace
const rtDoc = lensGraph.autoTransform(doc, 'org.relationaltext.facet')
const marks = rtDoc.features.filter(f =>
  typeof f.$type === 'string' && f.$type.startsWith('org.relationaltext.facet')
)

const md = to('markdown', rtDoc)

Feature Mapping: Hub → CommonMark ​

The relationaltext-to-commonmark.lens.json lens shows which hub features map to CommonMark and which are dropped or approximated.

Hub featureCommonMark featureNotes
boldstrong
italicemphasis
codecode-span
strikethroughstrikethroughGFM extension
underlineunderlineNon-standard; rendered as <u>
superscriptsuperscript
subscriptsubscript
keyboardkeyboard
highlightmarkRendered as <mark>
insertioninsauthor → cite, date → datetime
deletiondelauthor → cite, date → datetime
link (url)link (uri)Attr rename
mentionlink (uri)Flattened to a hyperlink
hashtaglink (uri)Flattened to a hyperlink
line-breakline-break
imageimage
paragraphparagraph
headingheadinglevel attr preserved
code-blockcode-blocklanguage attr preserved
horizontal-rulehorizontal-rule
page-breakhorizontal-ruleApproximated
detailsblockquote-markerApproximated
embed(dropped)No CommonMark equivalent

The Common Core ​

A feature is part of the common core if it has a corresponding representation in every output format. Bold survives because every supported format has a bold equivalent. Subscript may not survive in formats that lack inline-level subscript rendering.

Inline marks: bold, italic, code, strikethrough

Block elements: paragraph, heading (level 1–6), code-block, horizontal-rule

Entities: link (with url)

Features outside this core — underline, highlight, insertion, deletion, embed, page-break, details, social types (mention, hashtag) — are supported by specific format pairs and survive round-trips between those pairs. They are lost when exported to formats that have no equivalent (e.g., underline in plain CommonMark without HTML passthrough).

Lexicon Registration Detail ​

ts

// Called automatically — explicit call only needed in tests or custom setups
ensureRelationalTextLexicon()

ensureRelationalTextLexicon() calls register_lexicon(JSON.stringify(relationalTextLexiconData)) once. This bulk-registers all entries from formats/org.relationaltext/relationaltext.lexicon.json with the WASM lexicon, covering both the org.relationaltext.facet#* hub keys and the legacy org.relationaltext.richtext.mark#* / org.relationaltext.richtext.block#* programmatic API keys.

The WASM registry starts empty on every module load. Individual format importers register their own lexicons lazily (ensureXxxLexicon() called at the top of each from() / toXxxfunction). The hub lexicon is registered whencore.ts` is first imported — which happens transitively whenever any format module is loaded.