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:

ts
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:

ts
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.