Rich text / foldkit-richtext
Mark definitions
A mark is a definition, not a bare name. It says where typing continues it (boundary expansion) and, when it carries data, what schema that data satisfies. A Kit declares the vocabulary, so an editor extends it:
const Link = RichText.mark('Link', {
Props: Schema.Struct({ href: Schema.String }),
expand: 'none',
})
const ArticleKit = RichText.kit({
nodes: [RichText.block('Paragraph'), RichText.block('Heading')],
marks: [RichText.Bold, RichText.Italic, RichText.Code, Link],
})
RichText.run(state, command, ids, { marks: RichText.markRegistry(ArticleKit.marks) })A run stores a mark as a bare name when it has no props, and as
{ name, props } when it does. Loading preserves the form it found, so a
names-only document round-trips byte-equal, and since a run carries a name at
most once it never holds both forms of one mark:
Link.of({ href: '/docs' }) // { name: 'Link', props: { href: '/docs' } }of accepts decoded props and encodes them with the declared schema before
storing them as JSON. Transforming codecs therefore preserve their encoded
representation in the document. Encoding must require no services; invalid
props or a non-JSON-object encoded result throw at the builder call.
markName(mark) and markProps(mark) read either form, and sameMark decides
equivalence. Props are part of a mark's identity: two runs carrying the same name
with different props are not equivalent, so normalization does not merge them, and
AddMark is a set for its name — it appends, replaces props that differ, and
no-ops on the same value. RemoveMark keys on the name alone.
The shipped policy is Bold/Italic → after, Code → none, and a mark no
registry declares → both (so preservation never retargets an unknown mark
away). resolveInsertion retargets a boundary insertion when the current run
carries marks that forbid the edge and the neighbor carries exactly the marks
that remain, in nested blocks as at the top level; a registry changes which marks
those are, not the rule. At a block's outer edge there is no neighbor, so text typed
there with no stored marks gets a run of its own carrying only the marks that
extend: typing after a link or inline code that ends a paragraph leaves it. Declared
names are also what run may add, so a Kit's marks are usable by name or value.
validate(document, kit) is the publishing gate: it reports UnknownMark for a
name the Kit does not declare, and InvalidProps for a declared mark whose props
its Props schema refuses — including a mark that declares props but carries
none, and one carrying a field the schema does not declare.
The HTML fallback carries mark names; props travel in the slice format. A declared mark with props gets real attributes once a Kit-aware renderer exists.