Block capabilities, abilities and actions
A Block capability says what a Block supports: it is a Container block, it has a Source, it carries a Bibliographic record. It is code, and a Feature declares it. A Block type is data: it lists the capabilities its Typed blocks have and configures them. A Block action names the capabilities a Block must have (requires) and the ability its place in the document must have (ability).
Four words, four jobs:
| Word | Question it answers | Where it lives |
|---|---|---|
| Block capability | What is this Block? | EditorFeature.blockCapabilities (code) |
| Block type | Which capabilities does a Typed block of this type have, and how are they set? | a BlockType (data, JSON) |
| Ability | What is offered at this place in the document? | ABILITY ids; the abilities rule of each node and the host's abilities prop |
| Block action | What can the user run on this Block? | EditorFeature.blockActions, derive methods, the host's blockActions |
A capability describes and gates nothing by itself; an action gates on it through requires. It also never installs anything: the schema, the extensions and the node views stay with the Feature that owns the mechanism. A capability descriptor only tells the editor how to find out whether a Block has it, where its data lives and how to read it.
The library declares these capabilities: container and title are built in, the composites feature adds properties, source, bibliography, basket, titleHidden, ai-brief, references, import, export and derived. The capability contract lists them with their config.
Declare a capability
A host adds a capability as a Feature with blockCapabilities. The library's own pruefung Feature is the worked example: a Typed block has pruefung when its Block type lists it, and the Block type names the kinds of Prüfung it takes (redaktionell, fachlich). The same Feature adds the Block action „Prüfung eintragen“, which requires the capability. The snippet shows the shape of the shipped pruefungCapability; import it from @skaile/forge-editor/pruefung instead of declaring it again.
import { z } from 'zod'
import type { BlockCapability } from '@skaile/forge-editor/core'
import { listsCapability } from '@skaile/forge-editor/core'
// The config a Block type gives. It is validated when the editor is created.
const pruefungConfig = z.strictObject({
kinds: z.array(z.string().min(1)).min(1),
chain: z.boolean().default(false),
lock: z.string().min(1).optional(),
})
type PruefungConfig = z.infer<typeof pruefungConfig>
// The capability only describes: a Typed block has `pruefung` when its Block type lists it. This is the shape of the
// shipped `pruefungCapability` (`@skaile/forge-editor/pruefung`); the `pruefung` Feature owns the mechanism (records, hash, actions).
export const capability: BlockCapability = {
id: 'pruefung',
configSchema: pruefungConfig,
appliesTo: (node, ctx) => listsCapability(ctx.blockTypeOf(node), 'pruefung'),
read: (node, ctx) => ctx.configOf<PruefungConfig>(node, 'pruefung') ?? null,
}
The members of the descriptor:
idis unique across the preset. Block types andrequiresentries name it. A Feature may not use the id of a built-in capability, and two Features may not declare the same id:defineEditorthrows.appliesTo(node, ctx)decides whethernodehas the capability.listsCapability(ctx.blockTypeOf(node), id)answers "the Block type lists it". The result is memoised per node type and Block type id, so the predicate may read only the node type (its spec and flags),ctx.blockTypeIdand the Block type's config (ctx.configOf). A capability that must read an attribute of the node itself setsperNode: trueand keeps the check a constant-time attribute read (derivedreadsmeta.derivationthis way).configSchemais a zod schema for the config a Block type gives. Without it the capability takes no config and a Block type that gives one is refused.read(node, ctx)returns what the Block holds for the capability, ornull: the Source media child, the Basket id, the Bibliographic record. It runs only for Blocks that have the capability and is not memoised.storagedescribes where the data lives (metaKeys,childTypes), so conversion, Markdown and copy can treat it the same way. It is descriptive only.
appliesTo answers "does a Block of this type support it". What a single Block holds is Block contents, and read returns it. Keep the two apart.
Gate a Block action with requires
BlockAction.requires lists capability ids. The action is hidden on a Block that lacks one of them, and blockActionsOf throws when an id is declared by no Feature. It replaces node-name and Block-type-name checks in isVisible. ability is the second, independent gate: an action shows when the Block has every required capability and its place in the document has the ability (Abilities). blockTypes limits an action to named Block types and is only for an action that is truly specific to a type; if the action is about what a Block can do, use requires.
Abilities
An ability is a plain string id (ABILITY in @skaile/forge-editor/core/schema, or your own). Which abilities hold at a position follows from the nodes around it: the set starts with abilities.root (default ROOT_ABILITIES), then every ancestor from the outside in adds its grants and removes its revokes, and the innermost node finally adds its own. A code block revokes marks and headings, media and the title row revoke text editing, and a Typed block's own abilities (composite.meta, composite.derive, …) hold on the block itself, not on a paragraph inside it. A rule may be a function of the node, so it can read the node's attributes. Host rules passed through the abilities prop replace a node's own rule; the prop is read once at mount.
One ability gates many things. The editor checks it for:
| Item | Field | Effect where the ability is missing |
|---|---|---|
Toolbar and / entries | CommandDef.ability | disabled (toolbar) or not listed (/ menu) |
Mod-/Alt- shortcuts and input rules of a node or mark type | TYPE_ABILITIES | the key is swallowed; the typed text stays literal |
| Block actions and derive methods | BlockAction.ability, DeriveMethod.ability | hidden |
| „Umwandeln in …“ targets | TYPE_ABILITIES of the target type | not offered |
@ mentions, block references, AI rewrite and autocomplete | insert.mention, insert.reference, ai.* | the popup, menu or ghost text does not open |
An ability decides what the editor offers, not what may be written. Pasted and dropped content, collaborators' changes and commands called from code are not checked, plain typing and Backspace have no ability, and the editor knows no users: a host that wants per-role abilities passes a different abilities prop per mount. To refuse a change, use a transactionGuards contribution (Feature contract); to make a whole document read-only for a user, use authorize (Editor ports).
Let a Block type opt in
A Block type lists the capability with its config. The Block type goes through the blockTypes option of the composites feature (Block types).
import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import { type BlockType, defaultBlockTypes } from '@skaile/forge-editor/composites/schema'
import { coreContentFeature, coreUiFeature, createTranslator, defineEditor } from '@skaile/forge-editor/core'
import { pruefungFeature } from '@skaile/forge-editor/pruefung'
// A Block type opts into the capability and configures it.
const contract: BlockType = {
id: 'contract',
label: 'Vertrag',
icon: 'i-lucide-file-signature',
capabilities: [
{ id: 'container', config: { accepts: 'all' } },
{ id: 'pruefung', config: { kinds: ['redaktionell', 'fachlich'] } },
],
}
const blockTypes = [...defaultBlockTypes(createTranslator('de')), contract]
// A Block type that lists a capability no Feature declares throws when the editor is created; so does a block action that
// `requires` an undeclared id, when the actions are collected.
export const preset = defineEditor({
features: [
coreContentFeature(),
coreUiFeature(),
compositeFeature({ blockTypes }),
pruefungFeature(),
],
})
A Block that does not have the capability gets no „Prüfung eintragen“ button. A Typed block of the type contract gets it, with the config { kinds: ['redaktionell', 'fachlich'] } in capabilityConfigOf.
Read a capability at runtime
All of these take the editor, because the answer depends on the editor's own Block types:
| Function | Returns |
|---|---|
capabilitiesOf(editor, node) | the ids of the capabilities node has (ReadonlySet<string>) |
capabilitiesAt(editor, pos) | the same for the Block at pos; empty where no node starts |
capabilityConfigOf(editor, node, id) | the config the Block type gives the capability, or undefined |
capabilityDataOf(editor, node, id) | what read returns, or null when the Block lacks the capability or holds nothing |
They are exported from @skaile/forge-editor/core and run without a framework. The preset keeps the merged registry as preset.capabilities; pass it to parseBlockTypes as registry when a Block type file must name your capabilities (Block types).
Keep a capability small
A capability stays a descriptor plus a storage contract. If it needs a node, a node view or a settings tab, that belongs to a Feature (extensions, overlays, settingsTabs), and the capability only says that the Block has it. Blocks that are no Typed blocks can have a capability too, but a host block that wants title or properties must declare the chrome and the meta attribute itself: the capability tells the UI what to expect, not how to build the node.
Content tree mechanics
Work on a tree of Typed blocks: colour baskets and hidden titles, splitting by hand, by length or with AI, outline cleanup, focus and basket filter in the tree, and block transfer between two editors.
Block types in code and JSON
Define a Block type as data in code or in a JSON file, check it against the capability registry, and give the server the same list.