# A real code editor for Storyblok, built for my own blog

*Code, language, file name and highlighted lines in one field, instead of a textarea and a dropdown*

By Dominik Angerer · 4 November 2026 · https://d12r.io/e/storyblok-code-editor-field-plugin/

**TL;DR:** A Storyblok field with a CodeMirror editor, language picker, file name, highlighted lines and fullscreen. Copy the prompt and build it yourself.

Code in a textarea is painful. No highlighting, Tab jumps out of the field, and the language lives in a second field that editors forget to set. This field puts a real code editor into Storyblok, with the language, a file name and highlighted lines in the same value.

> **Try it yourself**
>
> Paste this into Claude Code or Cursor:
>
> ```
> Fetch and follow the Storyblok field plugin instructions at https://raw.githubusercontent.com/DominikAngerer/storyblok-field-wizard/main/generator/PROMPT.md and build this field:
> A code block field for a blog using CodeMirror 6: syntax highlighting, line numbers, bracket matching, undo, Tab / Shift+Tab indentation with spaces, and a soft-wrap toggle that is display-only and not stored. A toolbar has a language dropdown (TypeScript, TSX, JavaScript, JSX, JSON, HTML, CSS, Bash / Shell, Python, Markdown, YAML, Plain text), a line and character count, Wrap and Fullscreen; below the editor are a file name input and a highlight input. It stores { "code", "language", "filename", "highlight" }: code exactly as typed (never trimmed, "" when empty), language as one of typescript, tsx, javascript, jsx, json, html, css, bash, python, markdown, yaml, plaintext, filename trimmed to one line of max 200 characters or null, and highlight as 1-based lines and ranges normalised (sorted, merged, no spaces, so "3, 1-2,2" becomes "1-3") or null; highlighted lines are marked yellow in the editor, and an invalid entry like "2-x" shows an inline error and keeps the last valid value. Read old values without throwing: a plain string becomes { code, language: defaultLanguage, filename: null, highlight: null }, language aliases (ts, js, sh, zsh, md, yml, py, scss, xml, text, language-ts …) are resolved, unknown languages fall back to defaultLanguage, and anything else is an empty block; don't write on open. Options: defaultLanguage (default plaintext, aliases allowed, must be in allowedLanguages or the first allowed one is used), allowedLanguages (comma-separated, in that order, unknown entries skipped, a stored language outside the list is still shown as "not in allowed list"), tabSize (default 2, 1–8), maxLines (default 20, 6–200; the inline editor grows from 6 lines up to this and then scrolls), placeholder (default "Paste or type code…"). Save 300 ms after typing stops, and immediately on blur, on language change and when switching to or from fullscreen. Fullscreen opens a large editor in the portal modal with a visible Close button, toggled with Ctrl/⌘+Shift+Enter from anywhere; Esc then Tab leaves the editor. The field-plugin library closes the modal on any Escape that reaches document, so stop Escape inside CodeMirror (Esc closes the modal only when the editor isn't focused) and flush pending edits before the modal closes. The toolbar wraps at a narrow width.
> ```
>
> Already have the Claude Code plugin? Run:
>
> ```
> /storyblok-field:storyblok-field "A CodeMirror code block field with a language picker, file name, highlighted lines and a fullscreen editor, storing { code, language, filename, highlight }"
> ```
>
> It takes a few minutes and needs Node 22+. You only need a Storyblok personal access token if you want to deploy.

> **Good to know: plugin names are global**
>
> Field plugin names in Storyblok are shared across all spaces, not per space. If the field doesn't render correctly in the preview, or the deploy fails with `DUPLICATED_NAME`, someone else probably has that name already. Give yours a different one, for example `@yourhandle/code-editor`. It happened to me with this one: `code-editor` was taken, so it went live as `d12r-code-editor`.

## What it's for

This is the first of these fields that I built for myself. My blog, d12r.io, has a `code_block` component, and until now that was a plain textarea plus a separate language field. Every post with code meant typing in a box that eats your indentation. I asked [the same session](https://d12r.io/e/coordinator-pattern-agents-subagents-handoffs/) that had written the generation guide that night to build it, and it handed the job to a sub-agent working from that guide. My only instruction on the design: let's not restrict ourselves, breaking the old shape is fine. The new value bundles everything a blog code block renders: `{ code, language, filename, highlight }`. The field still reads the old plain strings, so existing posts keep working, and the migration happens on the next edit.

Inside, it's CodeMirror 6: highlighting for twelve languages, line numbers, bracket matching, undo and a wrap toggle. The editor grows with the code up to `maxLines` and then scrolls. For anything longer, there's a fullscreen view in Storyblok's modal.

## What you get

![The inline editor with TypeScript highlighting, file name src/greet.ts and lines 1 and 3 highlighted in yellow](https://a.storyblok.com/f/296339501219281/2144x2074/7abaa828e8/code-editor-04-filename-highlight-4692fd2f.png)

*Inline: TypeScript, a file name, and "3, 1" stored as `"1,3"` with both lines marked.*

![The fullscreen editor in Storyblok's modal with a Close button, toolbar, large editor and keyboard help](https://a.storyblok.com/f/296339501219281/2400x1800/0c8932eef8/code-editor-07-fullscreen-open-62d1a796.png)

*Fullscreen for longer snippets, with a visible Close button.*

![The field at about 330 px: the toolbar wraps onto two rows](https://a.storyblok.com/f/296339501219281/912x2210/f030bbb0ff/code-editor-90-narrow-d4b9b7ee.png)

*At narrow width the toolbar wraps, and nothing overflows.*

The stored value:

```json
{
  "code": "export function greet(name: string): string {\n  return `Hello, ${name}!`\n}",
  "language": "typescript",
  "filename": "src/greet.ts",
  "highlight": "1,3"
}
```

| Option | Default | What it does |
| --- | --- | --- |
| `defaultLanguage` | `plaintext` | Language for new blocks and old plain strings. Aliases like `ts` work. |
| `allowedLanguages` | all | Comma-separated languages offered, in this order. |
| `tabSize` | `2` | Spaces per indent level (1–8). |
| `maxLines` | `20` | Inline height in lines before it scrolls (6–200). |
| `placeholder` | `Paste or type code…` | Placeholder of the empty editor. |

## Using it in the frontend

```typescript
import { codeToHtml } from 'shiki'

const block = readCodeBlock(blok.code) // accepts the object or an old plain string
const lines = highlightedLines(block.highlight) // "1,3-5" → Set {1, 3, 4, 5}
const html = await codeToHtml(block.code, {
  lang: block.language,
  theme: 'github-light',
  transformers: [{ line(node, line) { if (lines.has(line)) this.addClassToHast(node, 'highlighted') } }],
})
```

The language ids are valid Shiki, Prism and highlight.js names. Both helpers are in the plugin's PLUGIN.md.

## What I learned building it

- **A real editor has a real price.** The bundle is 534 kB raw, 174 kB gzipped, about 3.4× the other fields, mostly CodeMirror. Good to know: field plugins deploy as one CommonJS file, so everything loads up front, with no lazy loading. For a field I use in every technical post, that's a trade-off I accept.
- **Escape is a shared key.** The browser test caught it: pressing Escape inside the fullscreen editor closed the modal. The `@storyblok/field-plugin` library closes the modal, without saving, on any Escape that reaches `document`. The editor now stops Escape inside CodeMirror and saves pending edits before the modal closes.
- **Debounce, then flush.** Edits are saved 300 ms after typing stops, but also right away on blur, on a language change and when switching to or from fullscreen. The browser test types a line and hits Close within the debounce window, and the line is still stored.
- **Keyboard users need a way out.** Tab indents, so it can't also move focus. CodeMirror's answer is Esc, then Tab, to leave the editor. Ctrl/⌘+Shift+Enter opens and closes fullscreen from anywhere.
- **Good to know: field type names are global.** Deploying `code-editor` returned `DUPLICATED_NAME`: another Storyblok user already had that name. It went live as `d12r-code-editor`, and the deploy script now takes `--name`. Tip: prefix your field names.

It has 23 unit tests and a browser walkthrough that passed 16 of 16 steps. The full story is in [the article about the original experiment](https://d12r.io/e/midnight-idea-generating-storyblok-field-plugins/). The other fields: [brand color picker](https://d12r.io/e/storyblok-brand-color-picker-field-plugin/), [product picker](https://d12r.io/e/storyblok-product-picker-field-plugin/), [author picker](https://d12r.io/e/storyblok-author-picker-field-plugin/), [recipe picker](https://d12r.io/e/storyblok-recipe-picker-fullscreen-field-plugin/).
