Rich text / foldkit-richtext

Kits

A Kit declares the vocabulary one editor accepts — node kinds and marks — as plain data, with no renderers or executable code:

ts
const ArticleKit = RichText.kit({
  nodes: [RichText.block('Paragraph'), RichText.block('Heading'), RichText.atom('Image')],
  marks: [RichText.Bold, RichText.Italic],
})

RichText.validate(document, ArticleKit)
// → [] when the document fits; otherwise a diagnostic the caller decides on

foldkit-richtext also ships a standard vocabulary — the kinds and marks a document uses to mean what Markdown and HTML also mean:

ts
RichText.kit({
  nodes: [...RichText.standardNodes, Callout],
  marks: [...RichText.standardMarks, Highlight],
})

standardNodes is Paragraph, Heading, Quote, List, ListItem, TaskItem, CodeBlock, ThematicBreak, Image, Table, TableRow, TableCell; standardMarks is the shipped three plus Strikethrough and Link. standardRendering gives each kind its element — a List an ol or ul, an Image an img with its src, a TaskItem its data-task — and renderingOver(base, extra) builds a registry over another, so an application adds its own kinds beside the standard ones. A TableRow that is the header renders data-header, and a Table's rows go in a tbody (the entry's inner), because an HTML parser inserts one around bare rows, and markup sent from a server has to parse to the tree that was rendered. An entry's adornments are elements drawn inside the block before its content that are not content: a task's checkbox, a figure's picture and caption, a callout's icon. They carry data-adornment, hold no position, and an HTML import drops them; one with toggles: 'open' flips that boolean prop when clicked, through the editor's ChangedProps, and one with an action ('copy') is reported to the application with its block, for something that is not an edit:

ts
RichText.renderingOver(RichText.standardRendering, {
  nodes: {
    TaskItem: block => ({
      tag: 'li',
      attributes: {},
      adornments: [
        {
          tag: 'input',
          attributes: { type: 'checkbox', ...(block.props.checked === true ? { checked: '' } : {}) },
          toggles: 'checked',
        },
      ],
    }),
  },
})

A standard Image also carries an optional caption, a width in CSS pixels, and an align (start, center, end); the standard rendering draws the img alone, and a rendering that draws a figure reads the rest. A link's href and an image's src pass safeUrl again as they are rendered, and one it refuses is left out, so a javascript: URL that reached the document by decoding, sync, or SetMark rather than import is drawn inert.

A kind can state rules stricter than its content mode. blocksOf(...kinds) accepts only those block kinds, marks: 'none' forbids marks on the kind's own runs, isolating: true makes the kind a boundary a lift never crosses, as a table cell is, splitProps names props an item split off one of this kind starts with (typed from the kind's Props), lineBreaks: true makes Enter a newline in the kind's text rather than a split (Enter on an empty last line leaves it for a paragraph after; the standard CodeBlock declares it), and within names the kinds it may stand in, so a ListItem stands only in a List and never at the top level:

ts
RichText.node('List', { children: RichText.blocksOf('ListItem', 'TaskItem') })
RichText.node('CodeBlock', { Props: Language, children: RichText.textContent, marks: 'none' })
RichText.atom('Image', { Props: Schema.Struct({ src: Schema.String }) })
RichText.node('TaskItem', {
  Props: Schema.Struct({ checked: Schema.Boolean }),
  children: RichText.blockContent,
  splitProps: { checked: false },
  within: ['List'],
})

validate reports those violations as UnexpectedChild, ForbiddenMark, and MisplacedNode (a kind outside what its within names), and checks an atom's props the same way it checks a node's. run refuses the edits that would create an unexpected child, a forbidden mark, or a misplaced kind (a move, a wrap, or a paste), when it is given the vocabulary — nodes: RichText.nodeRegistry(kit.nodes), the node counterpart of the marks option:

ts
RichText.run(state, { type: 'ToggleMark', mark: 'Bold' }, ids, {
  nodes: RichText.nodeRegistry(kit.nodes),
})
// → { ok: false, error: 'ForbiddenMark' } when the caret is inside a CodeBlock

The codec keeps a document a Kit would reject, and apply takes no vocabulary: a durable transaction must not depend on a declaration that may have moved. Removing a mark is always allowed, so a preserved document that already carries one has a way back.

validate reads the document and never repairs it: callers decide whether a diagnostic blocks publishing or shows a placeholder. The Kit does not drive parsing or apply.