Block types in code and JSON
A Block type is what the blockType attribute of a Typed block selects. It is data: an id, its presentation (label, icon, placeholders, chrome) and the Block capabilities it lists, each with its config. A capability that the type does not list is absent from its Typed blocks: no Block properties section, no Basket menu, no Block action that requires it. No other field on the type switches a behaviour.
Because a Block type has no functions, a host can write it in code or keep it in a JSON file. The Block type contract lists every field.
Block types in code
compositeFeature({ blockTypes }) takes the list of Block types of the editor. It replaces the built-in defaultBlockTypes(t) (section, task, note, folder, document, ...; labelled in the language of t), so spread the built-in list to keep it.
import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import { type BlockType, defaultBlockTypes } from '@skaile/forge-editor/composites/schema'
import type { PropertiesConfig } from '@skaile/forge-editor/composites/schema'
import { coreContentFeature, coreUiFeature, createTranslator, defineEditor } from '@skaile/forge-editor/core'
const t = createTranslator('de')
const decisionProperties = {
definitions: [
{ key: 'status', label: 'Status', type: 'select', options: [{ value: 'open', label: 'Offen' }, { value: 'done', label: 'Entschieden' }] },
{ key: 'owner', label: 'Zuständig', type: 'text' },
],
} satisfies PropertiesConfig
// A Block type is data: presentation plus the capabilities it lists, each with its config.
const decision: BlockType = {
id: 'decision',
label: 'Entscheidung',
icon: 'i-lucide-gavel',
titlePlaceholder: 'Was wird entschieden?',
capabilities: [
{ id: 'container', config: { accepts: ['task', 'note'] } },
{ id: 'basket' },
{ id: 'titleHidden' },
{ id: 'properties', config: decisionProperties },
],
}
// `blockTypes` replaces the built-in list, so spread `defaultBlockTypes(t)` to keep the built-in types.
export const preset = defineEditor({
features: [coreContentFeature(), coreUiFeature(), compositeFeature({ blockTypes: [...defaultBlockTypes(t), decision] })],
})
capabilities is a list of { id, config? } entries. The config of each entry is checked against the configSchema of its capability when the editor is created: an unknown capability id, a capability listed twice, a duplicate Block type id or an invalid config throws and names the Block type and the capability. The built-in capabilities and their configs are in the capability contract.
blockTypesOf(editor) returns the editor's own list. A blockType id that the list does not define shows with the presentation of section under its own id. A host Feature that adds Block types to the list of the preset uses EditorFeature.blockTypes (Feature contract); the lists of all Features concatenate in feature order and are validated together.
The pure accessors propertiesOf(type), sourceMediaOf(type), aiBriefOf(type) and acceptsOf(type) read a BlockType directly, without an editor; blockTypeConfigOf(type, id) (@skaile/forge-editor/core) reads the config of any capability. capabilitiesOf(editor, node) and capabilityConfigOf(editor, node, id) answer for a Block in an editor.
The tone, the chrome and the Accepted children of a Block type are described in Typed blocks.
Block types as JSON
A BlockType is plain data, so a host can keep its Block types in a JSON file (BlockType[], one array) and load them when it creates the editor. This is the file of the reference app examples/vue-two-pane, which defines the Block type „Idee“ only there:
[
{
"id": "idea",
"label": "Idee",
"icon": "i-lucide-lightbulb",
"titlePlaceholder": "Worum geht es bei der Idee?",
"descriptionPlaceholder": "Ein Satz dazu, was die Idee bringt",
"capabilities": [
{ "id": "container", "config": { "accepts": ["task", "note"] } },
{ "id": "basket" },
{ "id": "titleHidden" },
{
"id": "properties",
"config": {
"definitions": [
{
"key": "stage",
"label": "Reifegrad",
"type": "select",
"options": [
{ "value": "spark", "label": "Funke" },
{ "value": "sketch", "label": "Skizze" },
{ "value": "plan", "label": "Plan" }
]
},
{ "key": "impact", "label": "Wirkung (1–5)", "type": "number" },
{ "key": "champion", "label": "Ansprechperson", "type": "text" }
]
}
},
{ "id": "ai-brief", "config": { "purpose": "Eine Idee, die noch geprüft wird: der Inhalt hält Begründung, Varianten und offene Fragen; Aufgaben und Notizen darunter bereiten die Entscheidung vor." } },
{ "id": "export" }
]
}
]
parseBlockTypes(input, { registry? }) (@skaile/forge-editor/composites and /composites/schema, Vue-free) takes the text of the file or its parsed value and returns the BlockType[] that the blockTypes option takes. mergeBlockTypes(base, extra) layers the file over the built-in types: a type with a known id replaces that one in place, new types follow. The example keeps both calls in one module that the browser and the server import:
import { defaultBlockTypes, mergeBlockTypes, parseBlockTypes } from '@skaile/forge-editor/composites/schema'
import type { Translate } from '@skaile/forge-editor/core'
import file from './block-types.json'
// The Block types of this app: the library's built-in ones plus the ones `block-types.json` defines (the type „Idee“ exists
// only there). The browser (`composeEditor({ blockTypes })`) and the Nitro server (`installEditorServer({ blockTypes })`) both
// import this module, so the definitions live once. Parsing validates the file against the capabilities' config schemas; a
// mistake in it stops the server at start and the page at load, with the Block type, the capability and the path named.
const hostTypes = parseBlockTypes(file)
/** The built-in Block types in the language of `t`, with the types of `block-types.json` after them. */
export const blockTypesOf = (t: Translate) => mergeBlockTypes(defaultBlockTypes(t), hostTypes)
blockTypesOf(t) then goes to composeEditor({ blockTypes }), to compositeFeature({ blockTypes }) or to the blockTypes prop of AdvancedEditor.
What parseBlockTypes checks
Every entry is checked against the capability registry. By default that is the built-in capabilities and those of the composites feature. A host whose Features declare more capabilities passes registry: preset.capabilities.
id(letters, digits,_,-),labelandiconare required, and ids are unique.- A capability that the registry does not know is refused.
- One capability may be listed once.
- Its
configmust pass theconfigSchemaof the capability. A capability without a schema takes no config.
parseBlockTypes throws one BlockTypeJsonError for the whole file. message has one line per problem, and issues has the same as data (index, typeId, capability, path, message):
Block type "idea": capability "teleport" config: unknown capability; no feature declares it (known: container, title, properties, ...)
Block type "idea": capability "properties" config, definitions[1].type: Invalid option: expected one of "text"|"textarea"|...
Block type "idea": id: duplicate Block type id (first defined by entry 0)
A mistake in the file stops the server at start and the page at load, with the Block type, the capability and the path named.
The file has one language, so labels, placeholders and Property definition labels in it are not translated; the built-in types still follow the editor's language. Icons are i-lucide-* names. A bundler that only ships the icons it finds in the sources of the app (Nuxt UI) needs the file scanned too (examples/vue-two-pane/vite.config.ts).
In the example, „Idee“ appears in the / menu as /idea, in the Document tree and in the Block inspector. It has a Basket, the Block properties Reifegrad, Wirkung and Ansprechperson, an AI brief and the „Exportieren“ action, and it accepts tasks and notes as nested Typed blocks.
The server
The collaboration schema does not depend on Block types, because they are storage and not schema. Only the AI chat needs them on the server. Pass the same list to installEditorServer(nitroApp, { ports, blockTypes }):
import path from 'node:path'
import { createJsonAdapter } from '@skaile/forge-editor/adapters/json'
import { defineEditorConfig, serverConfig } from '@skaile/forge-editor/config'
import { createJsonCommentStore } from '@skaile/forge-editor/comments/json'
import { installEditorServer } from '@skaile/forge-editor/server/nitro'
import { defineNitroPlugin } from 'nitropack/runtime'
import { createTranslator } from '@skaile/forge-editor/core'
import { LOCAL_USER } from '../../shared/identity'
import { blockTypesOf } from '../../shared/block-types'
import { dataDir } from '../data-dir'
// The whole backend: documents as `<id>.json` (and the live collaboration state beside them) in one directory, comments
// in one JSON file, the editor's REST routes and the collaboration websocket on the Nitro app. The document index (titles
// and folders, which the JSON adapter does not keep) is served by `routes/api/two-pane-index.*`.
export default defineNitroPlugin((nitroApp) => {
installEditorServer(nitroApp, {
ports: {
...createJsonAdapter({
dir: path.join(dataDir, 'documents'),
// Everyone is the local user. Without this, the adapter signs in an anonymous dev user outside production only.
authenticate: async () => ({ id: LOCAL_USER.id, username: LOCAL_USER.name, color: LOCAL_USER.color }),
}),
comments: createJsonCommentStore({ file: path.join(dataDir, 'comments.json') }),
},
// `ai` (presets, roles, rate limit) and `collab` come from the one editor config; the AI routes need a key (`AI_KEY`).
...serverConfig(defineEditorConfig({ ai: { apiKey: process.env.AI_KEY } })),
// The same Block types as the browser (`shared/block-types.json` over the built-in ones): the AI chat describes them to the model itself.
blockTypes: blockTypesOf(createTranslator()),
})
})
EditorServerOptions.blockTypesis the option ofinstallEditorServer;AiHandlerContext.blockTypesis the same for hand-wired handlers, and a Nuxt host passesprovideEditorServer({ ports, blockTypes }).POST /api/ai/chatthen describes these types to the model (aiBlockTypesOf(blockTypes): id, label, Block properties as fields, theai-briefpurpose) and ignores theblockTypesin the request. Import the same JSON and loader on both sides so the definitions live once.- Without
blockTypesthe server takes theblockTypesof the client. Theconverttargetsand the hostblocksof the request still come from the client. - The server describes the host's types in the language of the translator it built them with (German for
createTranslator()).
blockTypes is storage on the server: it does not change the schema that the collaboration route builds.
Block capabilities, abilities and actions
Declare a Block capability in a host Feature, gate a Block action with requires and ability, see what abilities gate and let a Block type opt in.
Derive methods and the Derivation index
Register one way to make a block from another block, find the Derived blocks of an original and handle a missing source.