---
title: "Markdown and code"
description: "Edit Markdown files as blocks with frontmatter and unknown fences preserved, edit source files in CodeMirror, and pick a surface per file type."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/markdown-and-code"
---
# Markdown and code

> Edit Markdown files as blocks with frontmatter and unknown fences preserved, edit source files in CodeMirror, and pick a surface per file type.

`@skaile/forge-editor` edits documents that live as plain files in three entry families: Markdown as blocks (`/markdown`, `/markdown/vue`, `/markdown/react`), source files in CodeMirror 6 (`/code`, `/code/vue`, `/code/react`) and one component that picks between them by file type (`/document/vue`, `/document/react`). The Markdown surface keeps the frontmatter and every fence it does not understand byte for byte, so opening and saving a file without edits changes nothing.

<demo-frame name="vue-markdown">

## Markdown (`@skaile/forge-editor/markdown`)

### Framework-free API

- `splitFrontmatter(text)` / `joinFrontmatter(frontmatter, body)`: the YAML block at offset 0 only, kept verbatim.
- `markdownToContent(editor, body, { policy? }): { content, diagnostics }`. The policy `{ allowHtml?, allowDataUrls? }` decides what is dropped: raw HTML, links and media with unsafe URLs are dropped and reported as `MarkdownDiagnostic`s: `raw-html`, `unsafe-url`, `unknown-fence` (info), `unclosed-fence` and `invalid-fence-meta` (errors). `hasFatalDiagnostics` tells whether a result must not be committed.
- `contentToMarkdown(editor)`.
- `MarkdownClipboard.configure({ policy })`: an extension that pastes `text/markdown` and plain text with block syntax as blocks, sanitizes pasted HTML, and copies and cuts as `text/markdown` next to `text/html` and `text/plain`.
- `createSourceMode({ getEditor, toMarkdown?, fromMarkdown?, policy? }): SourceMode`: the `state` store is `{ mode: 'visual' | 'source', source, diagnostics }`; `enter()`, `edit(text)`, `commit(): boolean` (false with the editor untouched on a fatal diagnostic or a document the schema rejects) and `cancel()`.
- Unknown `::: kind` fences are `OpaqueFence` nodes (in `coreSchemaExtensions()`) that are written back byte for byte; Block types the Typed blocks feature registers parse first.

### Vue: `MarkdownSurface`

`MarkdownSurface` from `@skaile/forge-editor/markdown/vue`:

- `v-model` is the Markdown string, frontmatter included. An incoming value equal to the last emitted one is ignored.
- Props, emits and slots of `EditorSurface` are forwarded.
- Prop `policy` also configures the `MarkdownClipboard` it installs.
- Prop `sourceMode` takes a `SourceMode`: while its state is `source` the surface shows the draft in a text area with the diagnostics of a rejected commit and Apply/Cancel; `sourceLabels` supplies their texts.
- Event `diagnostics` reports the load diagnostics.

### React: `MarkdownSurface`

`MarkdownSurface` from `@skaile/forge-editor/markdown/react` takes `value` / `onChange` / `onFrontmatterChange` / `onDiagnostics`, a `policy` prop (it also configures the installed `MarkdownClipboard`) and the `EditorSurface` props forwarded, with the same semantics. It has no source view.

Neither binding imports CodeMirror.

## Code (`@skaile/forge-editor/code`)

CodeMirror 6 on a string, themed by the shared `--sk-*` tokens (`styles/tokens.css`; fallbacks are the token defaults).

### Framework-free API

`createCodeEditor({ parent, doc?, readOnly?, wordWrap?, language?, onSaveShortcut?, onChange? })`, `setReadOnly`, `setWordWrap`, `setLanguage(view, support)`, `loadLanguage(view, id)`, `setDocument(view, text)` (a host text, not an edit), `registerLanguage(id, loader, { core? })` / `resolveLanguage(id)` / `isCoreLang(id)` (language packs are the host's: register the `@codemirror/lang-*` packs you depend on; `jsonl` is built in), `formatJsonInEditor(view)`, `formatJsonText`, `jsonlLanguage`, `kindOf(path, mime?)` (`'markdown' | 'code' | 'binary'`) and `languageOf(path)`.

### Components

- `/code/vue`: `CodeSurface` (`v-model` string, `editable`, `language`, `wordWrap`; emits `update:modelValue`, `save` on Mod-S, `ready(view)`).
- `/code/react`: `CodeSurface` (`value`, `onChange`, `onSave`, `onReady`, same flags).

No ProseMirror or TipTap is reachable from these entries.

## One component for every file: `DocumentSurface`

`DocumentSurface` from `@skaile/forge-editor/document/vue` or `/document/react` renders by file type:

- `MarkdownSurface` for `kindOf(path, mime) === 'markdown'` (prop `markdown`: the Markdown surface's own props, `preset` at least).
- `CodeSurface` for text (language from `languageOf(path)` unless `language` is given).
- A `binary` slot (Vue) or `binary(path)` render prop (React) otherwise.

`ready` carries `{ kind: 'markdown', editor }` or `{ kind: 'code', view }`. These entries reach both editors; use `/markdown/*` or `/code/*` alone to keep one out of a bundle.

## Example: `examples/vue-markdown`

[`examples/vue-markdown`](https://github.com/skaile-ai/forge-editor/tree/main/examples/vue-markdown) is the demo above: a plain Vue app with no data layer that mounts `MarkdownSurface` on a sample document with frontmatter, a table, task items and an `::: note` fence of a Block type no feature knows. Run it with `bun run --cwd examples/vue-markdown dev` (port 3045).

What the example shows:

- **Byte-identical round trip.** The sample is already in the editor's normal form, so a faithful round trip returns it byte for byte. The page prints "Round trip without edits: byte-identical" until you edit; the unknown `::: note` fence stays exactly as written because it is an opaque block.
- **Preset.** `defineEditor` over `coreContentFeature()`, `coreUiFeature({ palette: EditorCommandPalette })` and `tablesFeature()`. The toolbar is a sibling that takes the editor from `@ready`.
- **Source mode.** The „Source" button calls `sourceMode.enter()` on a `createSourceMode` bound to the editor; the surface then shows the Markdown text, and a commit succeeds only when it parses without a fatal diagnostic. `useStoreRef(sourceMode.state)` turns the framework-free store into a ref.
- **Diagnostics.** The `diagnostics` event fills the "Load diagnostics" line; the last pane shows the current Markdown string.

The surface component and its state:

```vue [examples/vue-markdown/src/App.vue]
<script setup lang="ts">
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { createSourceMode, type MarkdownDiagnostic } from '@skaile/forge-editor/markdown'
import { MarkdownSurface } from '@skaile/forge-editor/markdown/vue'
import { tablesFeature } from '@skaile/forge-editor/tables'
import { EditorCommandPalette, EditorToolbar, useStoreRef } from '@skaile/forge-editor/vue'
import type { Editor } from '@tiptap/vue-3'
import { type Component, computed, ref, shallowRef } from 'vue'

// The sample is already in the editor's normal form, so a faithful round trip returns it byte for byte; the `::: note`
// fence is of a kind no feature knows, so it is an opaque block that is written back as it came.
const SAMPLE = `---
title: Sample
tags: [a, b]
---
# Heading

Some text with **bold**.

| Name | Count |
| --- | --- |
| a | 1 |
| b | 2 |

- [ ] open task
- [x] done task

::: note {"tone":"info"}
Unknown kinds   stay
exactly as written.
:::
`

const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature<Component>({ palette: EditorCommandPalette }), tablesFeature()] })
const text = ref(SAMPLE)
const editor = shallowRef<Editor>()
const edits = ref(0)
const unchanged = computed(() => edits.value === 0 && text.value === SAMPLE)
const loadDiagnostics = ref<MarkdownDiagnostic[]>([])

// Source mode: the whole document as Markdown text, committed only when it parses without a fatal diagnostic.
const sourceMode = createSourceMode({ getEditor: () => editor.value })
const source = useStoreRef(sourceMode.state)
const inSource = computed(() => source.value.mode === 'source')
</script>
```

The template mounts the surface with `v-model`, the preset, the source mode and the two events:

```vue [examples/vue-markdown/src/App.vue]
<template>
  <UApp>
    <main class="mx-auto flex max-w-4xl flex-col gap-4 p-6">
      <h1 class="text-xl font-semibold">@skaile/forge-editor – MarkdownSurface</h1>
      <p class="text-sm text-muted" data-testid="roundtrip">
        Round trip without edits: {{ unchanged ? 'byte-identical' : 'changed' }}
      </p>
      <div class="flex items-center gap-2">
        <EditorToolbar v-show="!inSource" :editor="editor" />
        <UButton
          v-if="!inSource"
          class="ml-auto"
          label="Source"
          icon="i-lucide-file-code"
          color="neutral"
          variant="outline"
          data-testid="source-enter"
          @click="sourceMode.enter()"
        />
      </div>
      <div class="rounded-lg border border-default p-4" data-testid="surface">
        <MarkdownSurface
          v-model="text"
          :preset="preset"
          :source-mode="sourceMode"
          @ready="editor = $event"
          @update:model-value="edits++"
          @diagnostics="loadDiagnostics = $event"
        />
      </div>
      <p class="text-sm text-muted" data-testid="diagnostics">
        Load diagnostics: {{ loadDiagnostics.length ? loadDiagnostics.map(d => `${d.code}${d.line ? `@${d.line}` : ''}`).join(', ') : 'none' }}
      </p>
      <pre class="max-h-96 overflow-auto rounded-lg bg-elevated p-3 text-xs" data-testid="markdown">{{ text }}</pre>
    </main>
  </UApp>
</template>
```

The app needs the editor context. `main.ts` installs Nuxt UI, then `createEditor` with the Nuxt UI kit and a memory backend (users, tags and uploads live in the page):

```ts [examples/vue-markdown/src/main.ts]
import ui from '@nuxt/ui/vue-plugin'
import { createMemoryBackend } from '@skaile/forge-editor/adapters/memory'
import { createEditor } from '@skaile/forge-editor/vue'
import { nuxtUiKit } from '@skaile/forge-editor/vue/kit/nuxt-ui'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

// No data layer: documents, dropped files (as blob: URLs), mention users and tags, and settings live in the page.
const backend = createMemoryBackend({
  users: [
    { id: 'u1', username: 'Ada Lovelace', color: '#7c3aed' },
    { id: 'u2', username: 'Alan Turing', color: '#0ea5e9' },
    { id: 'u3', username: 'Grace Hopper', color: '#16a34a' },
  ],
  tags: ['idee', 'entwurf', 'wichtig'],
})

createApp(App)
  .use(ui)
  .use(createEditor({ kit: nuxtUiKit(), backend }))
  .mount('#app')
```

Import `core.css` and `tables.css` in this order after Tailwind and Nuxt UI ([CSS and UI-provider requirements](https://skaile-ai.github.io/forge-editor/reference/css-and-ui)). In this documentation site the demo is the built example, embedded as an iframe.

</demo-frame>


## Sitemap

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