---
title: "Feature composition and entries"
description: "Compose an editor from features and presets, pick the right entries, and choose the integration depth from no data layer to a Nuxt server."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/composition"
---
# Feature composition and entries

> Compose an editor from features and presets, pick the right entries, and choose the integration depth from no data layer to a Nuxt server.

## Dimensions and integration depth

An editor is composed from three **dimensions** of features (`composeEditor` of `@skaile/forge-editor/vue/advanced` or `/react/advanced`; framework-free core in `@skaile/forge-editor/presets/compose`). The **backend** (`EditorBackend`; capabilities `documents`, `collaboration`, `assets`, `users`, `tags`, `settings`, `themes`, `ai`, `comments`) is a separate decision: it only prunes features whose capability it lacks, reported in `omitted`.

<table>
<thead>
  <tr>
    <th>
      Dimension
    </th>
    
    <th>
      Levels (or a list of feature ids)
    </th>
    
    <th>
      Features
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        content
      </code>
      
      : nodes, marks, structure; the server schema derives from it
    </td>
    
    <td>
      <code>
        'basic'
      </code>
      
       = <code>
        core-content
      </code>
      
      ; <code>
        'rich'
      </code>
      
       = <code>
        core-content
      </code>
      
      , <code>
        media
      </code>
      
      , <code>
        transcript
      </code>
      
      , <code>
        composites
      </code>
      
      , <code>
        mentions
      </code>
      
      , <code>
        authors
      </code>
      
      , <code>
        tables
      </code>
      
      , <code>
        composite-media
      </code>
    </td>
    
    <td>
      <code>
        core-content
      </code>
      
       is always installed
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        interaction
      </code>
      
      : editing UX without schema
    </td>
    
    <td>
      <code>
        'minimal'
      </code>
      
       = <code>
        core-ui
      </code>
      
      ; <code>
        'full'
      </code>
      
       = <code>
        core-ui
      </code>
      
      , <code>
        collab
      </code>
      
      , <code>
        comments
      </code>
      
      , <code>
        block-ui
      </code>
      
      , <code>
        themes
      </code>
      
      ; <code>
        []
      </code>
      
       = none (a viewer)
    </td>
    
    <td>
      <code>
        core-ui
      </code>
      
       is prepended to every non-empty list
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
      
      , or <code>
        true
      </code>
      
       = <code>
        ai
      </code>
      
       plus <code>
        composite-ai
      </code>
      
       (iff <code>
        composites
      </code>
      
      ) and <code>
        transcript-ai
      </code>
      
       (iff <code>
        transcript
      </code>
      
      )
    </td>
    
    <td>
      an integration whose <code>
        requires
      </code>
      
       are missing lands in <code>
        omitted
      </code>
    </td>
  </tr>
</tbody>
</table>

`add` appends host features after the catalog; `remove` drops catalog features and everything that `requires` them. Examples: viewer `composeEditor({ content: 'rich', interaction: [], ai: false })`; advanced without AI `composeEditor({ content: 'rich', interaction: 'full', ai: false })`; simple plus tables `composeEditor({ content: ['tables'], interaction: 'minimal', ai: false })`.

`transfer` (drag a Typed block from one editor or tree into another, with the copy transform and, with `ai: true`, the summary and bullet transforms of `integrations/composite-ai`) is in no level list, so a host opts in with `interaction: [...INTERACTION.full, 'transfer']` and configures it with `transfer: { transforms?, defaultTransform? }` ([Content tree mechanics](https://skaile-ai.github.io/forge-editor/guides/content-tree#block-transfer)). `compositeBaskets` replaces the built-in colour Baskets like `blockTypes` replaces the Block types ([Baskets](https://skaile-ai.github.io/forge-editor/guides/content-tree#baskets)).

`core-content` is configurable: `composeEditor({ content: 'basic', interaction: 'minimal', ai: false, coreContent: { headings: { levels: [1, 2] }, rule: false } })` (or `coreContentFeature(options)` in a hand-built preset) drops optional StarterKit structures from the editor. Options (`CoreContentOptions`): `headings: false | { levels }`, `lists: false` (bullet and ordered lists with their items), `blockquote: false`, `codeBlock: false`, `rule: false`; everything is on by default. A dropped structure is not in the schema, so the same options object also shapes the server schema (`schemaExtensionsOf(preset.features)`, or `coreContentSchemaExtensions(options)` from `@skaile/forge-editor/core/schema` for a schema built by hand); pass the same options on both sides. Commands that create a dropped node (toolbar button, `/` entry, ⌘K row) are hidden, not greyed out, and its shortcuts do not exist. A heading level outside `levels` hides just that level's command. Abilities are untouched: they stay a permission, not a schema switch. Content that holds a dropped structure (Markdown with `# Titel` and `headings: false`, stored JSON with a `heading` node) has no node type to land in: do not pass such documents to an editor with that option.

`block-ui` chooses where block actions appear: `composeEditor({ content: 'rich', interaction: 'full', ai: false, blockUi: { actions: 'both' } })` (or `blockUiFeature({ actions })` in a hand-built preset). `actions` (`BlockUiOptions`) is `'hover'` (default: the bar of the block under the pointer), `'toolbar'` (the bar of the block holding the selection, shown at its corner as soon as the caret is in it), `'both'` (one bar per block: where the pointer hovers the selected block only the toolbar shows) or `'none'` (drag handle and block targeting only, no action bar). Each action can narrow itself with `placement: 'hover' | 'toolbar' | 'both'` (default `'both'`). Abilities are not touched: an ability stays a permission ("can"), `blockUi.actions` only chooses where the allowed actions are offered. The toolbar bar carries the class `block-actions-toolbar` for host styling.

The catalog behind these dimensions is one table of rows in `presets/catalog`: one row per feature with its dimension, the level lists that hold it and whether a UI layer binds it (`ui`), and `presets/compose` adds how each is built from the `composeEditor` options. Its key order is the canonical order (content, interaction, AI), which the composed feature list and the toolbar order follow (the schema order is the explicit `SCHEMA_IDS` list of `presets/schema-ids`, a leaf the server's `schema/advanced` reads without the catalog); `CONTENT`, `INTERACTION` and `AI` are derived from it. Adding a catalog feature takes one row there, plus its id in `presets/schema-ids` when it has a schema; every entry that holds a part of it is keyed by the rows or that list, so a missing part (the builder, a `schema` source, the Vue, React or test binding of a `ui` row) fails to compile, and `UiBindings<C>` follows the `ui` rows. A framework-free feature without schema needs nothing else.

<table>
<thead>
  <tr>
    <th>
      Integration depth
    </th>
    
    <th>
      What the host runs
    </th>
    
    <th>
      Client call
    </th>
    
    <th>
      Server
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        No data layer
      </strong>
    </td>
    
    <td>
      nothing; the browser keeps the data
    </td>
    
    <td>
      <code>
        app.use(createEditor({ backend: createMemoryBackend({ documents, users, tags }) }))
      </code>
      
       (<code>
        @skaile/forge-editor/adapters/memory
      </code>
      
      ), then <code>
        composeEditor({ content: 'rich', interaction: 'full', ai: true, backend })
      </code>
      
      : <code>
        collab
      </code>
      
      , <code>
        comments
      </code>
      
      , <code>
        themes
      </code>
      
      , <code>
        ai
      </code>
      
       and its integrations are omitted automatically
    </td>
    
    <td>
      none
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Nuxt, built-in
      </strong>
    </td>
    
    <td>
      the Nuxt module with an <code>
        EditorPorts
      </code>
      
       bundle
    </td>
    
    <td>
      module <code>
        @skaile/forge-editor/nuxt
      </code>
      
       (config key <code>
        tiptapEditor
      </code>
      
      ); the plugin installs <code>
        createHttpBackend({ probe: true })
      </code>
      
      ; <code>
        <AdvancedEditor />
      </code>
      
       or <code>
        composeEditor({ …, backend })
      </code>
    </td>
    
    <td>
      <code>
        provideEditorServer({ ports: createJsonAdapter({ … }) })
      </code>
      
       (or <code>
        createDrizzleAdapter
      </code>
      
      , <code>
        createForgeAdapter
      </code>
      
      ) in a Nitro plugin, <a href="https://skaile-ai.github.io/forge-editor/guides/editor-ports">
        <code>
          EditorPorts
        </code>
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Nuxt, external data layer
      </strong>
    </td>
    
    <td>
      the Nuxt module with ports of your own over the external system
    </td>
    
    <td>
      same as the previous row
    </td>
    
    <td>
      <code>
        provideEditorServer({ ports: { identity, documents, assets, … } })
      </code>
      
      : implement only the ports the external system serves; the probe omits the rest on the client
    </td>
  </tr>
</tbody>
</table>

No Directus (or other CMS) adapter ships with the package: the seam for an external data layer is `EditorPorts`, unchanged.

## Bundles

A **bundle** is a named choice of the three dimensions, for hosts that want a starting point instead of picking levels. `composeBundle(bundle, options?)` (from `@skaile/forge-editor/vue/advanced` or `/react/advanced`; `composeBundle(ui, bundle, options?)` in the framework-free `@skaile/forge-editor/presets/compose`) expands the bundle from `BUNDLES` and calls `composeEditor`:

<table>
<thead>
  <tr>
    <th>
      Bundle
    </th>
    
    <th>
      <code>
        content
      </code>
    </th>
    
    <th>
      <code>
        interaction
      </code>
    </th>
    
    <th>
      <code>
        ai
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        simple
      </code>
    </td>
    
    <td>
      <code>
        'basic'
      </code>
    </td>
    
    <td>
      <code>
        'minimal'
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        writing
      </code>
    </td>
    
    <td>
      <code>
        media
      </code>
      
      , <code>
        tables
      </code>
      
      , <code>
        mentions
      </code>
    </td>
    
    <td>
      <code>
        block-ui
      </code>
      
      , <code>
        themes
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        collaborative
      </code>
    </td>
    
    <td>
      <code>
        media
      </code>
      
      , <code>
        tables
      </code>
      
      , <code>
        mentions
      </code>
      
      , <code>
        authors
      </code>
    </td>
    
    <td>
      <code>
        collab
      </code>
      
      , <code>
        comments
      </code>
      
      , <code>
        block-ui
      </code>
      
      , <code>
        themes
      </code>
    </td>
    
    <td>
      <code>
        false
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        advanced
      </code>
    </td>
    
    <td>
      <code>
        'rich'
      </code>
    </td>
    
    <td>
      <code>
        'full'
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
  </tr>
</tbody>
</table>

`BundleOptions` are the options of `composeEditor` without the three dimensions (`backend`, `add`, `remove`, `blockTypes`, `blockUi`, …), plus two adjustments by catalog id: `with` adds features on top of the bundle (each lands in the dimension its catalog row names; an AI feature switches `ai` on) and `without` takes features out, exactly like `remove` (features that `require` them land in `omitted`). A `with` id outside the catalog throws. Any other combination is a `composeEditor` call with explicit id lists.

```ts
// The writing bundle plus the transfer menu, without tables.
const preset = composeBundle('writing', { with: ['transfer'], without: ['tables'], backend })
```

`simpleEditor()` is `composeBundle(…, 'simple')` and `advancedEditor(options)` is `composeBundle('advanced', options)`; the `advancedEditor` options are `BundleOptions`, so the three dimensions are no longer overridable there (use `composeEditor` or a bundle for that).

## Entries

<table>
<thead>
  <tr>
    <th>
      Entry
    </th>
    
    <th>
      Side
    </th>
    
    <th>
      Contents
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        @skaile/forge-editor/nuxt
      </code>
    </td>
    
    <td>
      build
    </td>
    
    <td>
      The Nuxt module (<code>
        defineNuxtModule
      </code>
      
      , config key <code>
        tiptapEditor
      </code>
      
      ). Registers the Nitro scan dir, the client context plugin, AI runtime config, <code>
        build.transpile
      </code>
      
      , the <code>
        ui.css
      </code>
      
       Tailwind <code>
        @source
      </code>
      
      , Nitro <code>
        externals.inline
      </code>
      
       and <code>
        experimental.websocket
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/config
      </code>
    </td>
    
    <td>
      anywhere
    </td>
    
    <td>
      Framework-free <code>
        defineEditorConfig
      </code>
      
      , <code>
        serverConfig
      </code>
      
      , <code>
        publicConfig
      </code>
      
       and the types <code>
        EditorConfig
      </code>
      
      , <code>
        EditorConfigInput
      </code>
      
      , <code>
        EditorServerConfig
      </code>
      
      , <code>
        EditorPublicConfig
      </code>
      
      , <code>
        ModelPresetConfig
      </code>
      
      , <code>
        TranscriptionConfig
      </code>
      
      : the module options, the server options and the client options from one definition (see <a href="https://skaile-ai.github.io/forge-editor/guides/ai-configuration">
        AI configuration
      </a>
      
      ).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/server
      </code>
    </td>
    
    <td>
      server
    </td>
    
    <td>
      Framework-free (no Nuxt, h3, Nitro, Vue or React): <code>
        createCollabServer({ ports, schema, hooks })
      </code>
      
       → <code>
        { hocuspocus, handleConnection, forgetDocument, liveContent, contentChanged, stats, close }
      </code>
      
      , <code>
        createAiModels({ presets, apiKey, slots, transcription })
      </code>
      
      , <code>
        createRateLimiter({ max, windowMs })
      </code>
      
      , <code>
        EditorPorts
      </code>
      
       with <code>
        AccessControl
      </code>
      
      , <code>
        DocumentContentStore
      </code>
      
      , <code>
        CollabStateStore
      </code>
      
      , <code>
        Directory
      </code>
      
      , <code>
        AssetStore
      </code>
      
      , <code>
        SettingsStore
      </code>
      
      , <code>
        ThemeStore
      </code>
      
      , <code>
        ModelProvider
      </code>
      
      , <code>
        PORT_CAPABILITY
      </code>
      
      , <code>
        serverCapabilities
      </code>
      
      , <code>
        HttpError
      </code>
      
      . The Nitro installers (<code>
        installEditorServer
      </code>
      
      , <code>
        provideEditorServer
      </code>
      
      , <code>
        useEditorServer
      </code>
      
       of <code>
        @skaile/forge-editor/server/nitro
      </code>
      
      ) return the <code>
        EditorServer
      </code>
      
       handle with <code>
        forgetDocument
      </code>
      
      , <code>
        liveContent
      </code>
      
      , <code>
        enforceRateLimit
      </code>
      
       and <code>
        resolveModel
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/server/handlers
      </code>
    </td>
    
    <td>
      server
    </td>
    
    <td>
      One handler per editor/AI route, <code>
        (ctx, request) => Promise<HandlerResponse>
      </code>
      
       (<code>
        usersGet
      </code>
      
      , <code>
        tagsGet
      </code>
      
      , <code>
        settingsGet/Put
      </code>
      
      , <code>
        assetGet/Post
      </code>
      
      , <code>
        themesGet
      </code>
      
      , <code>
        themeGet/Post/Delete
      </code>
      
      , <code>
        modelsGet
      </code>
      
      , <code>
        chatPost
      </code>
      
      , <code>
        generatePost
      </code>
      
      , <code>
        completePost
      </code>
      
      , <code>
        extractPost
      </code>
      
      , <code>
        transcriptCleanupPost
      </code>
      
      ) and the zod schemas of their bodies (<code>
        chatBodySchema
      </code>
      
      , …). Map any HTTP server's request onto <code>
        HandlerRequest
      </code>
      
       and send the <code>
        HandlerResponse
      </code>
      
      ; <code>
        HttpError
      </code>
      
       carries the status.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/server/node
      </code>
    </td>
    
    <td>
      server (Node)
    </td>
    
    <td>
      <code>
        listenCollab(collab, { port, host })
      </code>
      
      : a Node HTTP server that serves a <code>
        createCollabServer
      </code>
      
       over websockets, for a plain script (needs the <code>
        crossws
      </code>
      
       dependency; Node only).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/collab
      </code>
      
      , <code>
        /collab/vue
      </code>
      
      , <code>
        /collab/react
      </code>
    </td>
    
    <td>
      browser
    </td>
    
    <td>
      framework-free <code>
        collabFeatureCore(ui?)
      </code>
      
      , <code>
        yjsAnchorResolver
      </code>
      
      ; <code>
        collabFeature()
      </code>
      
       per surface
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/block-ui
      </code>
      
      , <code>
        /block-ui/vue
      </code>
      
      , <code>
        /block-ui/react
      </code>
    </td>
    
    <td>
      browser
    </td>
    
    <td>
      framework-free <code>
        blockUiFeatureCore(ui, options?)
      </code>
      
      , <code>
        blockActionsFor
      </code>
      
      , <code>
        selectedBlockOf
      </code>
      
      , <code>
        effectiveTarget
      </code>
      
      , <code>
        BLOCK_CORNER_ATTR
      </code>
      
      , <code>
        blockCornerOf
      </code>
      
      ; <code>
        blockUiFeature(options?)
      </code>
      
      , <code>
        BlockUi
      </code>
      
      , <code>
        BlockActionsOverlay
      </code>
      
       per surface
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/authors
      </code>
      
      , <code>
        /authors/vue
      </code>
      
      , <code>
        /authors/react
      </code>
    </td>
    
    <td>
      browser
    </td>
    
    <td>
      framework-free <code>
        authorsFeatureCore(ui)
      </code>
      
      , <code>
        currentAuthor
      </code>
      
      , <code>
        documentAuthors
      </code>
      
      ; <code>
        authorsFeature()
      </code>
      
       plus <code>
        AuthorColorsButton
      </code>
      
      , <code>
        AuthorColorsPanel
      </code>
      
      , <code>
        SettingsAuthorsTab
      </code>
      
       per surface (see <a href="https://skaile-ai.github.io/forge-editor/guides/authors-and-comments">
        Authors and comments
      </a>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/comments
      </code>
      
      , <code>
        /comments/vue
      </code>
      
      , <code>
        /comments/react
      </code>
    </td>
    
    <td>
      browser
    </td>
    
    <td>
      framework-free <code>
        commentsFeatureCore(ui, { model? })
      </code>
      
      , <code>
        createCommentsModel
      </code>
      
      ; <code>
        commentsFeature({ model? })
      </code>
      
       (requires <code>
        collab
      </code>
      
       unless a host <code>
        model
      </code>
      
       backs it), <code>
        useComments
      </code>
      
      , <code>
        CommentsMargin
      </code>
      
      , <code>
        CommentsPopover
      </code>
      
      , <code>
        CommentsListMenu
      </code>
      
      , … per surface
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/themes
      </code>
      
      , <code>
        /themes/vue
      </code>
      
      , <code>
        /themes/react
      </code>
    </td>
    
    <td>
      browser
    </td>
    
    <td>
      framework-free <code>
        themesFeatureCore(ui)
      </code>
      
      , <code>
        startObsidianTheme
      </code>
      
      , <code>
        pinObsidianTheme
      </code>
      
      , <code>
        scopeThemeCss
      </code>
      
      , <code>
        THEME_SCOPE_CLASS
      </code>
      
      ; <code>
        themesFeature()
      </code>
      
       (site-wide Obsidian themes; see <a href="https://skaile-ai.github.io/forge-editor/guides/themes">
        Themes
      </a>
      
      ) and <code>
        SettingsAppearanceTab
      </code>
      
       per surface; Vue-free in <code>
        /themes/schema
      </code>
      
      : <code>
        ThemeRecord
      </code>
      
      , <code>
        THEME_ID
      </code>
      
      , <code>
        MAX_THEME_BYTES
      </code>
      
      , <code>
        themeManifestSchema
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/schema/advanced
      </code>
    </td>
    
    <td>
      anywhere
    </td>
    
    <td>
      Vue-free <code>
        advancedSchemaExtensions()
      </code>
      
      ; imports no Vue, UI or browser code
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/styles/collab.css
      </code>
      
      , <code>
        /block-ui.css
      </code>
      
      , <code>
        /authors.css
      </code>
      
      , <code>
        /comments.css
      </code>
      
      , <code>
        /themes.css
      </code>
    </td>
    
    <td>
      browser
    </td>
    
    <td>
      Remote carets, block-range selection, author colouring, comment highlights and cards, the Obsidian variable bridge. The Nuxt module adds every feature stylesheet to <code>
        css
      </code>
      
       (<code>
        themes.css
      </code>
      
       last).
    </td>
  </tr>
</tbody>
</table>

The server logic (`src/server/`) runs in any Node process: `createCollabServer`, `createAiModels`, `createRateLimiter` and the route handlers take their ports and configuration explicitly. The HTTP routes are one table, `API` (`src/core/api-routes.ts`: method, path, Port and request and response shape of every route), which the HTTP backend reads too; `serverRoutesOf(ports, { ai })` (`src/server/routes.ts`) pairs each row with its handler and serves a route only when its port is present. One installer, `installEditorServer` (`@skaile/forge-editor/server/nitro`), mounts that table and the collab websocket on a Nitro app and returns the `EditorServer` handle (`forgetDocument`, `liveContent`, `enforceRateLimit`, `resolveModel`); the Nuxt module calls it from its server plugin and a plain Nitro app from a plugin of its own. A plain script needs no Nitro at all:

```ts
import { createCollabServer } from '@skaile/forge-editor/server'
import { listenCollab } from '@skaile/forge-editor/server/node'

const collab = createCollabServer({ ports }) // ports.identity.authenticate({ headers, token }) decides who may connect; needs ports.collabState
const { url, close } = await listenCollab(collab, { port: 4000 })
// browser: new HocuspocusProvider({ url, name: documentId, document, token })
```

## Per-feature entries: framework-free, Vue, React

Every feature that has a UI is split in the same four entries, so a host takes exactly the layers it uses:

<table>
<thead>
  <tr>
    <th>
      Entry
    </th>
    
    <th>
      Contents
    </th>
    
    <th>
      May import
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        ./<x>
      </code>
    </td>
    
    <td>
      The feature's logic: <code>
        <x>FeatureCore(ui)
      </code>
      
      , the <strong>
        models
      </strong>
      
       (<code>
        create*
      </code>
      
      , <code>
        *Model
      </code>
      
      : framework-free stores such as <code>
        createCommentsModel
      </code>
      
      , <code>
        createCompositeRows
      </code>
      
      , <code>
        createAiAssistant
      </code>
      
      , <code>
        createDocumentChat
      </code>
      
      , <code>
        modelProfiles(app)
      </code>
      
      ), Tiptap extensions and plugins, helpers
    </td>
    
    <td>
      Tiptap/ProseMirror, <code>
        /core
      </code>
      
      , <code>
        /settings
      </code>
      
      ; no <code>
        vue
      </code>
      
      , <code>
        react
      </code>
      
      , <code>
        @nuxt/ui
      </code>
      
       or <code>
        sonner
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ./<x>/vue
      </code>
    </td>
    
    <td>
      <code>
        <x>Feature(): VueFeature
      </code>
      
      , the feature's Vue components and node views, thin Vue <strong>
        hooks
      </strong>
      
       (<code>
        useComments
      </code>
      
      , <code>
        useCompositeRows
      </code>
      
      , <code>
        useAiAssistant
      </code>
      
      , <code>
        useModelProfiles
      </code>
      
      , <code>
        useFileImport
      </code>
      
      , …) over the models
    </td>
    
    <td>
      <code>
        vue
      </code>
      
      , <code>
        @tiptap/vue-3
      </code>
      
      , Nuxt UI; never <code>
        react
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ./<x>/react
      </code>
    </td>
    
    <td>
      <code>
        <x>Feature(): ReactFeature
      </code>
      
      , the React components (shadcn/ui) and node views, the React hooks over the same models
    </td>
    
    <td>
      <code>
        react
      </code>
      
      , <code>
        @tiptap/react
      </code>
      
      , Radix, <code>
        sonner
      </code>
      
      ; never <code>
        vue
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ./<x>/schema
      </code>
    </td>
    
    <td>
      Node/mark specs, protocol schemas and limits for servers and validators
    </td>
    
    <td>
      no UI, no DOM
    </td>
  </tr>
</tbody>
</table>

Entries that exist per feature: `media`, `transcript`, `composites`, `authors`, `comments`, `ai`, `collab`, `block-ui`, `themes` (with `/schema` where the feature owns document nodes or a protocol), `outline` and `tree` (`/vue`, `/react`, no `Core(ui)`). `mentions` is framework-free alone (`mentionsFeature(): EditorFeature<never>`, usable in both layers). The three integrations (`integrations/composite-media`, `composite-ai`, `transcript-ai`) are framework-free alone too: they only wire other features' models, so `compositeMediaFeature()`, `compositeAiFeature()` and `transcriptAiFeature()` fit a Vue and a React preset unchanged.

- **The UI binding contract.** `features/<x>/feature.ts` exports `<x>FeatureCore<C>(ui: <X>Ui<C>): EditorFeature<C>`: all feature behaviour (extensions, commands, toolbar items, slash commands, settings tabs, conversions) with the UI left open as the typed interface `<X>Ui<C>` (for example `CommentsUi`: `toolbarButton`, `overlay`, `settingsTab`; `MediaUi`: `recorderModal`, `nodeViews`). `C` is the framework's component type. `./<x>/vue` fills it with Vue components, `./<x>/react` with React components; a host writing its own chrome (another UI kit, a test) calls `<x>FeatureCore(ui)` with its own components. `EditorFeature<never>` means “no components at all”: such a feature (`coreContentFeature()`, `coreUiFeature()`, `mentionsFeature()`, the integrations) is accepted by both presets.
- **Notices.** Features never import a toast library. A user-facing message is a `Notice` (`{ id?, title, description?, kind?, icon?, duration? }`) sent to a `Notifier` (`(notice) => void`, optional `dismiss(id)`; `silentNotifier` drops everything), from `/core`. A feature reads it as `ctx.notify` in its factories (`FeatureContext.notify`), app-level models (model profiles, the rate-limit notice) as `EditorContext.notify`, set with `createEditorContext({ notify })`. Vue: `vueNotifier(useToast())` shows Nuxt UI toasts (the page needs `<UApp>`); `createEditor()` installs a *queued* notifier (`createQueuedNotifier()`) that holds notices raised before the first `EditorSurface` exists and shows them through that surface's `useToast()`. React: `reactNotifier` shows sonner toasts; pass it as `createEditorContext({ notify: reactNotifier })` and mount sonner's `Toaster` (re-exported from `@skaile/forge-editor/react`) once per page, otherwise no notice is visible. `id` replaces a visible toast, `duration: 0` keeps it until replaced or dismissed.
- **setup and teardown.** A feature's `setup(ctx)` runs once per mounted surface and may return a teardown function that the surface runs when it unmounts. Per-app runtimes dedupe inside and hand back a release: `startObsidianTheme(ctx)` is reference-counted per app, so the React `themesFeature()` removes the theme when the last surface that started it unmounts, while the Vue `themesFeature()` (call it during component setup) keeps it for the life of the Vue app through the `lifetime` hook of `ThemesUi`, because the theme styles the whole site. Run own per-app side effects there instead of in a component.
- **Models versus hooks.** State and logic are framework-free `createStore` stores (from `/core`: `createStore`, `derived`, `ReadonlyStore`) inside the models; the hooks only subscribe: React `useStore(store)` (from `/react`), Vue `useStoreRef(store)` (a readonly ref, from `/vue`; `useWritableStoreRef(store)` for two-way refs, `trackedStore(store)` for a store whose `get()` Vue tracks, `storeFromRef(ref)` the other way round), all from the one `vue/store-ref` module, used inside the thin `use*` hooks of the feature. Notices follow one policy on both surfaces: `notifierOf(editor, app)` (from `/core`) picks the editor's surface notifier, else the app context's, when a notice is shown; `useNotifier(editor?)` (from `/vue` and `/react`) is its hook form, so a host that passes `createEditor({ notify })` / `createEditorContext({ notify })` receives every notice of the hooks, including the AI rate-limit notice (`useAiAssistant(editor?)`, `useDocumentChat(editor?)`, `useFileImport(editor?)`). A host that wants a different framework (or a script) uses the model directly.

## Feature composition

An editor is a list of features. Every feature is imported from its own entry, so a feature you do not import is not in your bundle:

```ts
import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { compositeMediaFeature } from '@skaile/forge-editor/integrations/composite-media'
import { mediaFeature } from '@skaile/forge-editor/media/vue'

// Order = order of toolbar groups, menu entries and the schema.
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), mediaFeature(), compositeFeature(), compositeMediaFeature()] })
```

- `defineEditor` checks `requires`: an integration listed without the features it connects (`commentsFeature()` needs `collab`; `compositeMediaFeature()` needs `composites` and `media`; `compositeAiFeature()` needs `composites`, `ai`, `media`; `transcriptAiFeature()` needs `transcript`, `ai`) throws at composition time. `bun run check:editor-composition` keeps that behavior.
- Own blocks and features: see the tutorial chapters [Your own block (path A)](https://skaile-ai.github.io/forge-editor/tutorial/own-block-path-a) and [Your own feature (path B)](https://skaile-ai.github.io/forge-editor/tutorial/own-feature-path-b).
- Prebuilt compositions: `composeEditor(options)` per surface (`@skaile/forge-editor/vue/advanced`, `@skaile/forge-editor/react/advanced`) builds an `EditorPreset` from the three dimensions of the [table at the top](#dimensions-and-integration-depth) plus an optional `backend`: `composeEditor({ content: 'rich', interaction: 'full', ai: true, backend })` returns the preset with `omitted` (`{ id, reason }[]`: a feature dropped because it `requires "<id>"` or because `backend "<name>" lacks "<capability>"`). `advancedEditor({ blockTypes?, …overrides })` (`{ content: 'rich', interaction: 'full', ai: true }`) is a one-line wrapper; the framework-free `simpleEditor()` (`@skaile/forge-editor/presets/compose`: `{ content: 'basic', interaction: 'minimal', ai: false }`, no ⌘K palette) is the component-free simple preset; `SimpleEditor` (`/vue/simple`, `/react/simple`) and `AdvancedEditor` (`/vue/advanced`, `/react/advanced`) are the matching components. `@skaile/forge-editor/presets/compose` is the framework-free assembly (`presets/compose.ts`: `composeEditor(ui, options)`, `CONTENT`, `INTERACTION`, `AI`, `UiBindings<C>`, `ComposeOptions<C>`, `ComposedEditor<C>`, `simpleEditor()`): a UI layer binds only the UI-bound features (core UI, media, transcript, composites, authors, comments, AI and its integrations, collab, block UI, themes), the framework-free ones (core content, mentions, tables, composite-media) are built there; it contains no component and is not a preset to mount. The order is canonical (content, then interaction, then AI, then `add` in the given order), so schema and toolbar order do not depend on how the options were written.
- Both surfaces take the same features: a React preset is `defineEditor({ features: [coreContentFeature(), coreUiFeature(), mediaFeature(), commentsFeature(), …] })` with the factories from `./<x>/react`, a Vue one with those from `./<x>/vue`. Mixing is a type error (`VueFeature` is `EditorFeature<Component>`, `ReactFeature` is `EditorFeature<ComponentType<{ editor: Editor }>>`); only `EditorFeature<never>` features (`coreContentFeature(), coreUiFeature()`, `mentionsFeature()` and the integrations) fit either ([quick start for React](https://skaile-ai.github.io/forge-editor/getting-started/quick-start-react)).
- The preset, and with it the schema, collaboration mode and slash commands, is read once when the surface mounts. Change it by remounting with a new `key`. A simple editor cannot safely edit advanced JSON: unknown nodes are rejected through `contentError` ([simple editor tutorial](https://skaile-ai.github.io/forge-editor/tutorial/simple-editor)).
- Components that belong *beside* an editor are separate exports and take the editor as a prop (the toolbar and the settings components find its preset through it). Vue: `EditorToolbar`, `EditorSettingsPanel`, `EditorSettingsModal` (`/vue`), `EditorOutline` (`/outline/vue`), `CompositeTable`, `CompositeInspector` (`/composites/vue`), `AiSidebar` (`/ai/vue/chat`, optional peer `@skaile/forge-chat`), `CommentsMargin` (`/comments/vue`), `DocumentTree` (`/tree/vue`).
- The React equivalents have the same names and props style: `EditorToolbar`, `EditorSettingsPanel`, `EditorSettingsModal` from `@skaile/forge-editor/react`; `EditorOutline` from `/outline/react`; `CompositeTable`, `CompositeInspector` from `/composites/react`; `AiSidebar` from `/ai/react`; `CommentsMargin` from `/comments/react`; `DocumentTree` from `/tree/react`. The host mounts sonner's `Toaster` once.
- `EditorToolbar` (Vue and React) takes `overflow`: `'wrap'` (default) breaks its groups onto further lines; `'collapse'` keeps one line and folds the groups that do not fit, from the end, into one dropdown each (the first command's icon and a chevron; `component` commands, such as the comments and author-colour toggles, stay as buttons). Give the toolbar a bounded width, e.g. `class="w-full"` in a header row.
- A feature can run per-app side effects from every surface's setup without touching the surface markup: its `setup: (ctx) => void | (() => void)` contribution is called by `EditorSurface` for each mounted surface and its returned teardown when the surface unmounts (`themesFeature()` starts its theme runtime this way; the runtime dedupes per app and puts `obsidian-theme theme-dark|theme-light` on `<html>` so the whole app follows the theme; see [Per-feature entries](#per-feature-entries-framework-free-vue-react) for its lifetime on each surface).

## Browser entries versus schema and server entries

<table>
<thead>
  <tr>
    <th>
      Entry group
    </th>
    
    <th>
      Entries
    </th>
    
    <th>
      May import
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Browser, with Vue and Nuxt UI
    </td>
    
    <td>
      <code>
        /vue
      </code>
      
      , <code>
        /vue/simple
      </code>
      
      , <code>
        /vue/advanced
      </code>
      
      , <code>
        /<feature>/vue
      </code>
      
       (<code>
        /media
      </code>
      
      , <code>
        /transcript
      </code>
      
      , <code>
        /authors
      </code>
      
      , <code>
        /comments
      </code>
      
      , <code>
        /composites
      </code>
      
      , <code>
        /ai
      </code>
      
      , <code>
        /collab
      </code>
      
      , <code>
        /block-ui
      </code>
      
      , <code>
        /themes
      </code>
      
      , <code>
        /outline
      </code>
      
      , <code>
        /tree
      </code>
      
      )
    </td>
    
    <td>
      everything above them in the rules of <a href="https://skaile-ai.github.io/forge-editor/reference/dependency-rules">
        Dependency rules
      </a>
      
      ; never React
    </td>
  </tr>
  
  <tr>
    <td>
      Browser, with React and shadcn/ui
    </td>
    
    <td>
      <code>
        /react
      </code>
      
      , <code>
        /react/simple
      </code>
      
      , <code>
        /react/advanced
      </code>
      
      , <code>
        /<feature>/react
      </code>
      
       (the same features)
    </td>
    
    <td>
      the same rules; never Vue, <code>
        @tiptap/vue-3
      </code>
      
       or Nuxt UI
    </td>
  </tr>
  
  <tr>
    <td>
      Browser, framework-free
    </td>
    
    <td>
      <code>
        /core
      </code>
      
       (<code>
        defineEditor
      </code>
      
      , <code>
        createEditorContext
      </code>
      
      , the settings store, <code>
        createStore
      </code>
      
      , the notice seam, the suggestion renderer seam, descriptors such as <code>
        EditorFeature<C>
      </code>
      
       and <code>
        MenuItem
      </code>
      
      ), <code>
        /<feature>
      </code>
      
       (<code>
        <x>FeatureCore(ui)
      </code>
      
      , the models), <code>
        /mentions
      </code>
      
      , <code>
        /integrations/*
      </code>
      
      , <code>
        /presets/*
      </code>
    </td>
    
    <td>
      Tiptap/ProseMirror, <code>
        /core
      </code>
      
       and <code>
        /settings
      </code>
      
       only (plus Yjs/AI SDK where the feature needs them); no <code>
        vue
      </code>
      
      , <code>
        @tiptap/vue-3
      </code>
      
      , <code>
        @nuxt/ui
      </code>
      
      , <code>
        react
      </code>
      
       or <code>
        sonner
      </code>
      
       module
    </td>
  </tr>
  
  <tr>
    <td>
      Server-safe schema and protocol, usable in Node
    </td>
    
    <td>
      <code>
        /core/schema
      </code>
      
      , <code>
        /schema/simple
      </code>
      
      , <code>
        /schema/advanced
      </code>
      
      , <code>
        /media/schema
      </code>
      
      , <code>
        /mentions/schema
      </code>
      
      , <code>
        /transcript/schema
      </code>
      
      , <code>
        /authors/schema
      </code>
      
      , <code>
        /comments/schema
      </code>
      
      , <code>
        /composites/schema
      </code>
      
      , <code>
        /themes/schema
      </code>
      
      , <code>
        /tree/types
      </code>
      
      , <code>
        /settings
      </code>
      
      , <code>
        /ai/protocol
      </code>
      
      , <code>
        /integrations/transcript-ai/protocol
      </code>
    </td>
    
    <td>
      no UI framework, no DOM globals or feature factories
    </td>
  </tr>
  
  <tr>
    <td>
      Server
    </td>
    
    <td>
      <code>
        /server
      </code>
      
      , <code>
        /nuxt
      </code>
    </td>
    
    <td>
      Nitro/Nuxt build code; never imported by browser code
    </td>
  </tr>
</tbody>
</table>

Servers and validators build their ProseMirror schema from the framework-free schema entries (`getSchema(advancedSchemaExtensions())`), never from a browser entry.

## Choosing a preset or features

<table>
<thead>
  <tr>
    <th>
      Goal
    </th>
    
    <th>
      Use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Rich text only, no backend, smallest bundle
    </td>
    
    <td>
      <code>
        SimpleEditor
      </code>
      
       (with the ⌘K palette) / <code>
        simpleEditor()
      </code>
      
       from <code>
        /presets/compose
      </code>
      
       (without it); features <code>
        core-content
      </code>
      
      , <code>
        core-ui
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Everything, Nuxt module as the backend
    </td>
    
    <td>
      <code>
        AdvancedEditor
      </code>
      
       / <code>
        advancedEditor({ blockTypes? })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A level in between: advanced minus a feature, or a small editor with AI
    </td>
    
    <td>
      <code>
        <AdvancedEditor :remove="['tables']" />
      </code>
      
      , <code>
        <AdvancedEditor content="basic" interaction="minimal" :ai="true" />
      </code>
      
      : <code>
        AdvancedEditor
      </code>
      
       takes <code>
        content
      </code>
      
      , <code>
        interaction
      </code>
      
      , <code>
        ai
      </code>
      
      , <code>
        add
      </code>
      
      , <code>
        remove
      </code>
      
      , <code>
        backend
      </code>
      
      , <code>
        blockTypes
      </code>
      
      , <code>
        blockUi
      </code>
      
       (which surfaces show block actions: <code>
        'hover'
      </code>
      
      , <code>
        'toolbar'
      </code>
      
      , <code>
        'both'
      </code>
      
      , <code>
        'none'
      </code>
      
      ) and <code>
        coreContent
      </code>
      
       (headings, lists, blockquote, code block or rule left out of the schema), all read once at setup, builds the preset with <code>
        composeEditor
      </code>
      
       and exposes it (<code>
        preset
      </code>
      
      , with <code>
        omitted
      </code>
      
      ). <code>
        SimpleEditor
      </code>
      
       stays lean (core features only; its bundle holds no catalog feature) and takes just <code>
        add
      </code>
      
      : <code>
        <SimpleEditor :add="[aiFeature()]" />
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Something else, or the advanced set plus an own feature
    </td>
    
    <td>
      <code>
        defineEditor({ features: [...] })
      </code>
      
       mounted with <code>
        EditorSurface
      </code>
      
      , or <code>
        advancedEditor({ add: [ownFeature()] })
      </code>
    </td>
  </tr>
</tbody>
</table>

Advanced list in schema order: `core-content`, `core-ui`, `media`, `transcript`, `composites`, `mentions`, `authors`, `comments`, `ai`, `collab`, `block-ui`, `composite-media`, `composite-ai`, `transcript-ai`, `themes`. `transfer` is not part of it; add it through the `interaction` list as above.

<table>
<thead>
  <tr>
    <th>
      Feature (factory, entries)
    </th>
    
    <th>
      Needs (<code>
        requires
      </code>
      
      )
    </th>
    
    <th>
      Backend
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        coreContentFeature()
      </code>
      
       (<code>
        /core
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      none
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        coreUiFeature(ui?)
      </code>
      
       (<code>
        /core
      </code>
      
      ; <code>
        ui = { palette }
      </code>
      
       adds the ⌘K palette: the <code>
        SimpleEditor
      </code>
      
       components and the <code>
        composeEditor()
      </code>
      
      /<code>
        advancedEditor()
      </code>
      
       of <code>
        /vue/advanced
      </code>
      
       and <code>
        /react/advanced
      </code>
      
       pass the framework's <code>
        EditorCommandPalette
      </code>
      
      ; the framework-free <code>
        simpleEditor()
      </code>
      
       and a hand-built <code>
        defineEditor
      </code>
      
       list have none unless they pass it themselves)
    </td>
    
    <td>
      none
    </td>
    
    <td>
      none
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mediaFeature()
      </code>
      
       (<code>
        /media/vue
      </code>
      
      , <code>
        /media/react
      </code>
      
      ; <code>
        mediaFeatureCore
      </code>
      
       in <code>
        /media
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      uploads (<code>
        backend.assets
      </code>
      
      ; HTTP: <code>
        /api/editor/assets/:id
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mentionsFeature()
      </code>
      
       (<code>
        /mentions
      </code>
      
      , no components: both surfaces)
    </td>
    
    <td>
      none
    </td>
    
    <td>
      <code>
        /api/editor/users
      </code>
      
      , <code>
        /api/editor/tags
      </code>
      
       or host <code>
        metadata
      </code>
      
       lists
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transcriptFeature()
      </code>
      
       (<code>
        /transcript/vue
      </code>
      
      , <code>
        /transcript/react
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      none
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        authorsFeature()
      </code>
      
       (<code>
        /authors/vue
      </code>
      
      , <code>
        /authors/react
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      none (author comes from the collaboration session <code>
        user
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        collabFeature()
      </code>
      
       (<code>
        /collab/vue
      </code>
      
      , <code>
        /collab/react
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      <code>
        /collab/:id
      </code>
      
       websocket; active only when <code>
        collaboration
      </code>
      
       is passed to the surface
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        commentsFeature({ model? })
      </code>
      
       (<code>
        /comments/vue
      </code>
      
      , <code>
        /comments/react
      </code>
      
      )
    </td>
    
    <td>
      <code>
        collab
      </code>
      
      , none with <code>
        model
      </code>
    </td>
    
    <td>
      comments live in the Y.Doc, or in the host's <code>
        CommentsModel
      </code>
      
       (<code>
        model: getEditor => CommentsModel
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        compositeFeature({ blockTypes? })
      </code>
      
       (<code>
        /composites/vue
      </code>
      
      , <code>
        /composites/react
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      none (also mounts the Typed block overlay: status/tag chips on each title line and, on the Block type icon, a popover with the Block type dropdown (warns before <code>
        changeBlockType
      </code>
      
      , naming the Block property values that would be lost), the Block type's properties and the summary; also read-only: Block type as text, properties disabled; enforces the Block types' Accepted children, <code>
        BlockType.accepts
      </code>
      
      : an edit that nests a Typed block into one that does not accept its Block type is refused with the notice <code>
        composites.nesting.title
      </code>
      
       („Verschachtelung nicht möglich“))
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        aiFeature()
      </code>
      
       (<code>
        /ai/vue
      </code>
      
      , <code>
        /ai/react
      </code>
      
      ; framework-free <code>
        aiFeatureCore
      </code>
      
       in <code>
        /ai
      </code>
      
      ; the Vue sidebar is the separate <code>
        /ai/vue/chat
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      <code>
        /api/ai/{models,generate,chat,complete}
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        blockUiFeature()
      </code>
      
       (<code>
        /block-ui/vue
      </code>
      
      , <code>
        /block-ui/react
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      none (drag handle, hover actions)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        themesFeature()
      </code>
      
       (<code>
        /themes/vue
      </code>
      
      , <code>
        /themes/react
      </code>
      
      )
    </td>
    
    <td>
      none
    </td>
    
    <td>
      adapter <code>
        listThemes
      </code>
      
      /<code>
        loadTheme
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transferFeature({ transforms?, defaultTransform? })
      </code>
      
       (<code>
        /transfer/vue
      </code>
      
      , <code>
        /transfer/react
      </code>
      
      ; framework-free <code>
        transferFeatureCore
      </code>
      
       in <code>
        /transfer
      </code>
      
      )
    </td>
    
    <td>
      <code>
        composites
      </code>
      
       (<code>
        requires
      </code>
      
      ); none else (<code>
        summaryTransform
      </code>
      
       and <code>
        bulletsTransform
      </code>
      
       of <code>
        integrations/composite-ai
      </code>
      
       are offered only with an AI backend and the <code>
        typedBlock
      </code>
      
       node in the schema)
    </td>
    
    <td>
      none for <code>
        copy
      </code>
      
      ; <code>
        summary
      </code>
      
       and <code>
        bullets
      </code>
      
       use <code>
        POST /api/ai/generate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        compositeMediaFeature()
      </code>
      
       (<code>
        /integrations/composite-media
      </code>
      
      , both surfaces)
    </td>
    
    <td>
      <code>
        composites
      </code>
      
      , <code>
        media
      </code>
    </td>
    
    <td>
      uploads
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        compositeAiFeature()
      </code>
      
       (<code>
        /integrations/composite-ai
      </code>
      
      , both surfaces)
    </td>
    
    <td>
      <code>
        composites
      </code>
      
      , <code>
        ai
      </code>
      
      , <code>
        media
      </code>
    </td>
    
    <td>
      AI plus <code>
        /api/ai/extract
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transcriptAiFeature()
      </code>
      
       (<code>
        /integrations/transcript-ai
      </code>
      
      , both surfaces)
    </td>
    
    <td>
      <code>
        transcript
      </code>
      
      , <code>
        ai
      </code>
    </td>
    
    <td>
      AI plus <code>
        /api/ai/transcript-cleanup
      </code>
    </td>
  </tr>
</tbody>
</table>

```ts
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { mediaFeature } from '@skaile/forge-editor/media/vue' // or '/media/react' with a React surface
import { mentionsFeature } from '@skaile/forge-editor/mentions'

// The models come from the app context, so no component setup is needed for the AI feature (themesFeature() of the Vue binding is the exception: call it in setup).
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), mediaFeature(), mentionsFeature()] })
```

```vue
<EditorSurface v-model="content" :preset="preset" @ready="editor = $event" />
```

`defineEditor` keeps the given order (toolbar groups, menu entries, schema) and throws on a duplicate id, on a `requires` entry that is not in the list and on a second contributor of a singleton hook (`parseMarkdown`, `segment`, `structure`, `migrateContent`, `anchorResolver`, `formatAiResult`). It installs nothing for you. A Vue list takes the factories from `/<x>/vue`, a React list those from `/<x>/react`; mixing them is a type error (`VueFeature` is `EditorFeature<Component>`, `ReactFeature` is `EditorFeature<ComponentType<{ editor }>>`), while features without components (`coreContentFeature()`, `coreUiFeature()`, `mentionsFeature()`, the integrations) fit both. A host that brings its own UI kit calls `<x>FeatureCore(ui)` with its own components ([per-feature entries](#per-feature-entries-framework-free-vue-react)). When you use feature entries directly in a plain app, also import that feature's stylesheet. Per-feature usage: the other guides of this section; composition and entries: this page.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
