Status: design note with the v1 helper/storage/editor slice implemented. app/richText.ts validates and renders canonical documents; ActivityPub local post serialization renders canonical rich-text payloads plus ActivityStreams mention/hashtag tags; Matrix, ATProto/Bluesky, Farcaster, and Nostr-like exports are covered by tests; remote ActivityPub object previews expose canonical rich-text projections for review; group content projection exposes stored canonical payloads as body text plus validated JSON; backend post create/update input accepts contentRichText; static-site post metadata renders canonical rich text through the safe HTML renderer; and geesome-ui renders post cards plus writes native composer text as canonical rich text. Future protocol modules should add typed adapters instead of treating raw HTML as editable source.
GeeSome should not use raw HTML as the canonical source format for user post text.
The canonical post-text format should be a small, versioned, semantic rich-text document. HTML is still needed, but only as an adapter/rendering output for ActivityPub, Matrix, static sites, admin previews, and legacy clients. Protocols that do not use HTML as their source format should receive plain text plus structured annotations.
Raw HTML is a rendering format, not a stable social-content model. It is difficult to validate consistently, unsafe to render without sanitizer discipline, awkward to migrate, and poorly matched to protocols such as ATProto, Farcaster, and Nostr-style plaintext events.
A typed document keeps the important user intent:
- text blocks and inline formatting;
- links, mentions, hashtags, and spoilers as structured marks;
- attachments as content-addressed references;
- deterministic rendering into each protocol's preferred representation.
It also fits GeeSome's IPFS/IPLD direction better than arbitrary HTML. A compact structured object can be encoded as JSON now and as DAG-CBOR/IPLD later without changing the logical schema.
- Keep user-authored post text portable across ActivityPub, Matrix, ATProto/Bluesky, Farcaster, Nostr-like protocols, static sites, and GeeSome clients.
- Make every renderer escape text by default and explicitly opt into safe marks/tags.
- Preserve attachment identity by
storageIdinstead of embedding large bytes or remote HTML. - Keep v1 deliberately small so import/export tests can cover the whole format.
- Allow migration from legacy
text/htmlpost contents without trusting the original HTML.
- Do not build a full page-layout or WYSIWYG editor schema in v1.
- Do not support arbitrary
style,class,iframe,script, SVG, forms, event handlers, or raw HTML nodes. - Do not make ActivityPub/Matrix HTML the source of truth.
- Do not add database migrations only for this design note.
Preferred canonical payload MIME type:
application/vnd.geesome.rich-text+json
During rollout, legacy clients can continue writing text/plain or sanitized text/html. New rich-text-aware clients should write the canonical JSON payload and optionally keep derived HTML/plain-text caches where existing render paths require them.
The v1 object is JSON-compatible and should remain DAG-CBOR-friendly:
{
"type": "geesome.richText",
"version": 1,
"lang": "en",
"blocks": [
{
"type": "paragraph",
"children": [
{"text": "Hello "},
{"text": "GeeSome", "marks": [{"type": "strong"}]},
{"text": " "}
]
},
{
"type": "paragraph",
"children": [
{
"text": "Follow the project",
"marks": [
{
"type": "link",
"href": "https://example.com/geesome",
"title": "GeeSome project"
}
]
}
]
},
{
"type": "attachment",
"storageId": "bafy...",
"mimeType": "image/png",
"alt": "Screenshot of the published post"
}
],
"source": {
"protocol": "activitypub",
"objectId": "https://remote.example/users/alice/statuses/1"
}
}| Field | Required | Notes |
|---|---|---|
type |
yes | Must be geesome.richText. |
version |
yes | Integer schema version. First version is 1. |
lang |
no | BCP-47 language tag when known. |
blocks |
yes | Ordered block list. Empty documents should use []. |
attachments |
no | Optional attachment metadata list when attachments are referenced separately from inline blocks. |
source |
no | Optional remote/import provenance. Never used as a trust shortcut. |
V1 should allow only these block types:
| Type | Fields | Render Meaning |
|---|---|---|
paragraph |
children |
Normal text paragraph. |
blockquote |
children |
Quoted text block. |
codeBlock |
text, optional language |
Preformatted code. Text is escaped. |
list |
ordered, items |
Ordered or unordered list. Items contain child blocks or inline children. |
listItem |
children |
Internal list item block. |
lineBreak |
none | Explicit break when needed inside imported content. |
attachment |
storageId, mimeType, optional alt, title, size, width, height |
Content-addressed media/file reference. |
Inline content is text-first. Text nodes may carry marks:
{"text": "example", "marks": [{"type": "em"}]}Allowed marks:
| Mark | Fields | Notes |
|---|---|---|
strong |
none | Bold/strong emphasis. |
em |
none | Italic/emphasis. |
code |
none | Inline code. Text is escaped. |
strike |
none | Strikethrough. |
spoiler |
optional summary |
Render as supported by the target; degrade to text. |
link |
href, optional title |
Only safe protocols are allowed. |
mention |
id, optional name, protocol, href |
Actor/user reference. |
hashtag |
name, optional href |
Tag name without # preferred. |
Link href should allow only these protocols unless a future adapter explicitly adds more:
httphttpsipfsipnsmailto
Protocol-relative URLs and scriptable protocols such as javascript: must be rejected during import and rendering.
Writers and importers should normalize documents before saving:
- remove empty text nodes unless they are the only child needed to preserve an intentionally empty block;
- merge adjacent text nodes with identical marks;
- sort marks in a deterministic order;
- trim unsupported attributes from marks and blocks;
- validate attachment
storageIdvalues before linking them to post content; - cap text length, block count, mark count, and nesting depth according to API limits;
- preserve source provenance separately from rendered text.
Do not rewrite user-visible whitespace aggressively. Protocol exporters can collapse or wrap text according to their target rules.
The canonical payload can start as JSON stored through the existing content pipeline. The shape should remain compatible with deterministic DAG-CBOR later:
- no functions, dates, undefined, NaN, or cyclic structures;
- integer
version; - arrays for ordered content;
- strings for protocol IDs and storage IDs;
- small metadata maps with stable known keys;
- attachment bytes stored as separate content-addressed objects, referenced by
storageId.
If this becomes an IPLD object, storageId fields can later become typed links where the surrounding manifest format supports them. The logical schema should not depend on whether the current transport stores it as JSON text, DAG-JSON, or DAG-CBOR.
Inbound ActivityPub/Matrix/legacy HTML must follow this order:
- Parse HTML with a real parser.
- Remove blocked elements and attributes with a conservative allowlist.
- Reject unsafe URL protocols.
- Convert allowed structure into the canonical rich-text document.
- Store the original remote object only as remote-source/audit data.
- Render future previews from the canonical document or sanitized derived output, never from original remote HTML.
HTML tags that have no canonical equivalent should be unwrapped or dropped. For example, <span style="color:red">text</span> becomes plain text; <iframe> is removed; <script> is removed.
Plain text imports should become paragraph blocks. Optional link/mention/hashtag detection may add marks only when the importer can do so deterministically and safely.
ATProto/Farcaster/Nostr-style imports should preserve protocol-specific IDs in source and convert facets/tags/mentions into canonical marks. Byte offsets from the source protocol must be validated against the decoded text before conversion.
Adapters should be pure functions from canonical rich text to target representation.
| Target | Output |
|---|---|
| ActivityPub | Sanitized HTML content, plain text summary/fallback when useful, ActivityStreams tag objects for mentions/hashtags, attachment objects for media. Status: local post content and canonical rich-text mention/hashtag tag output are implemented. |
| Matrix | Plain body plus formatted_body with format: org.matrix.custom.html; HTML from the same conservative renderer. Status: richTextToMatrixMessageContent exports m.text body/formatted_body payloads. |
| ATProto/Bluesky | Plain text plus facets for links, mentions, and tags. Unsupported marks become plain text. Status: richTextToAtProtoTextWithFacets exports deterministic UTF-8 byte-indexed link, DID mention, and tag facets. |
| Farcaster | Plain text plus mentions, byte-based mention positions, and embeds. Unsupported marks become plain text. Status: richTextToFarcasterCast exports CastAdd-style text, embeds, empty embedsDeprecated, mentions, and mentionsPositions, removes valid FID mention display text from the cast body, preserves invalid mentions as plain text, and bounds safe link embeds to Farcaster's two-embed shape. |
| Nostr-like notes | Plain text plus protocol tags for links, mentions, and hashtags where supported. Status: richTextToNostrTextNote exports plain content plus r, p, and t tags for safe links, 64-hex pubkey mentions, and hashtags. |
| Static site | Sanitized HTML generated from canonical rich text, plus escaped title/meta/plain snippets. Status: static-site content sanitization prefers validated canonical rich-text JSON and renders it through richTextToSafeHtml, with legacy text HTML still sanitized as a fallback. |
| Search/snippets | Plain text only, no HTML. |
Every adapter should have fixtures for unsafe input, nested marks, links, mentions, hashtags, attachments, empty docs, and unsupported features.
- Keep reading legacy
text/plainandtext/htmlcontent. - Add helper APIs that convert legacy content into canonical rich text at render/import boundaries.
- Start writing new post text as
application/vnd.geesome.rich-text+json. - Generate sanitized HTML/plain text from canonical content for older render paths.
- Add optional backfill only after restored production data confirms the conversion is safe and bounded.
Existing Content rows do not need a schema migration for the design itself. A later implementation can decide whether rich-text JSON is a new content object, an additional projection/cache, or a first-class post-body relation.
- Original remote HTML is untrusted even if the ActivityPub HTTP signature is valid. The signature proves actor transport identity, not HTML safety.
- Sanitized derived HTML is display output, not source of truth.
- Renderers must escape all text and explicitly render only supported marks.
- Links must be protocol-checked both during import and rendering.
- Mentions and hashtags should be structured marks, not injected HTML.
- Attachments are storage references and must still pass existing visibility, delete-safety, and serving rules.
geesome-node:
- canonical schema validation;
- import/export helpers;
- ActivityPub/Matrix/social adapter boundaries;
- API and static-site/admin rendering safety;
- tests for imported remote content and export fixtures.
geesome-libs:
- shared schema types;
- deterministic fixture helpers;
- optional IPLD/DAG-CBOR encode/decode helpers;
- portable plain text/facet conversion helpers.
geesome-ui:
- editor model mapping. Status: the composer writes canonical rich text through
contentRichText; - client-side preview rendering through the same allowlist. Status: post cards render canonical rich text instead of trusting raw HTML;
- e2e coverage for post component rendering and editor round trips. Status: desktop/mobile post-card and composer e2e coverage exists in geesome-ui.
Recommended first code PR:
- Add schema constants/types and validation helpers in a small module. Status: implemented in
app/richText.ts. - Add
richTextToPlainTextandrichTextToSafeHtml. Status: implemented inapp/richText.ts. - Add
htmlToRichTextfor the current allowed HTML subset. Status: implemented inapp/richText.ts. - Add fixtures that prove unsafe HTML cannot survive the round trip. Status: implemented in
test/richText.test.ts. - Wire low-risk render/review paths to the helpers before replacing broader post storage. Status: ActivityPub local post
contentserialization renders canonical rich-text payloads and falls back to escaped legacy text for invalid payloads; cached remote ActivityPub object previews expose canonical rich-text converted from sanitizedcontentfor admin review/import follow-ups. - Add deterministic plain-text facet export for ATProto-style protocols. Status: implemented as
richTextToAtProtoTextWithFacetswith UTF-8 byte-offset fixtures intest/richText.test.ts. - Add ActivityStreams tag export for canonical rich-text mentions and hashtags. Status: implemented as
richTextToActivityPubTagsand wired into local ActivityPub Note serialization. - Add Matrix message content export. Status: implemented as
richTextToMatrixMessageContentwith plain fallback and sanitizedorg.matrix.custom.htmlfixtures. - Add Nostr-like text note export. Status: implemented as
richTextToNostrTextNotewith plain content plusr/p/tprotocol tag fixtures. - Add Farcaster cast export. Status: implemented as
richTextToFarcasterCastwith text, safe URL embeds, FID mentions, and byte-position fixtures. - Add shared stored-content projection helpers so canonical rich-text content rows can be read as plain body text plus validated JSON by post APIs and protocol serializers. Status: implemented in
app/modules/group/contentProjectionHelpers.tsand wired intogroup.prepareContentData. - Add backend native write input so post create/update can receive a validated
contentRichTextdocument and save it as a normalContentView.Contentsrow. Status: implemented inapp/modules/group/postContentInputHelpers.tsand wired intogroup.createPost/group.updatePost. - Render canonical rich-text post content through the generated static-site safe HTML path instead of downgrading it to plain text first. Status: implemented in
app/modules/staticSiteGenerator/helpers.ts. - Render canonical rich text in the Vue post component without trusting raw HTML. Status: implemented in geesome-ui post-card rendering.
- Wire the Vue post composer to submit canonical rich-text
contentRichTextbodies. Status: implemented in geesome-ui native composer publishing.
Do not change the storage format for all posts in the first implementation PR. The v1 slice keeps canonical rich text as a normal content row and leaves legacy text/plain / sanitized text/html reads compatible.
- Should the canonical helper live first in
geesome-node, then move togeesome-libs, or start ingeesome-libsfor shared UI/node use? - Should rich-text payloads be separate content attachments or a first-class
Postbody relation? - Which mention IDs should GeeSome prefer for local users/groups: static IDs, ActivityPub actor URLs, or protocol-specific aliases?
- Should imported remote HTML preserve an audit-only sanitized HTML snapshot, or is the original remote object plus canonical conversion enough?