pyreon

@pyreon/code — API Reference

Generated from code's src/manifest.ts — the same source that powers llms.txt and MCP get_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see code.

Reactive code editor for Pyreon built on CodeMirror 6 — the core editor is ~138 KB gz (measured), ~7x lighter than Monaco's ~940 KB gz core. editor.value is a writable Signal<string> — reads track reactively, writes push back into CodeMirror. 19 language grammars lazy-loaded on demand. Canvas-based minimap, diff editor, tabbed editor, and two-way signal binding with built-in loop prevention.

Peer dependencies: @pyreon/runtime-dom — install alongside this package.

Features

  • createEditor — reactive instance with writable Signal<string> value

  • createTabbedEditor — reactive multi-file editor (reactive tabs + open/close/switch/move)

  • CodeEditor, DiffEditor, TabbedEditor JSX components

  • bindEditorToSignal — two-way binding with built-in loop prevention

  • 19 language grammars via loadLanguage (lazy-loaded)

  • Canvas-based minimapExtension for code overview

  • Built on CodeMirror 6 — measured core ~138KB gz vs Monaco ~940KB gz ESM core (~7x smaller gzipped)

Complete example

A full, end-to-end usage of the package:

import { createEditor, createTabbedEditor, CodeEditor, DiffEditor, TabbedEditor, bindEditorToSignal, loadLanguage, minimapExtension } from '@pyreon/code'
import { signal } from '@pyreon/reactivity'

// Create a reactive editor instance
const editor = createEditor({
  value: 'const x = 1',
  language: 'typescript',
  theme: 'dark',
  minimap: true,
  lineNumbers: true,
  onChange: (next) => console.log('user edit:', next),
})

// editor.value is a writable Signal<string>
editor.value()           // read reactively — tracks in effects/JSX
editor.value.set('new')  // write back into CodeMirror
editor.cursor()          // computed { line, col }
editor.lineCount()       // computed number

// Mount with JSX component:
<CodeEditor instance={editor} style="height: 400px" />

// Diff editor — side-by-side comparison:
<DiffEditor original="old code" modified="new code" language="typescript" />

// Two-way binding to external signal (e.g. form field, store):
interface Config { host: string; port: number }
const config = signal<Config>({ host: 'localhost', port: 3000 })

const configEditor = createEditor({ value: JSON.stringify(config(), null, 2), language: 'json' })

const binding = bindEditorToSignal({
  editor: configEditor,
  signal: config,
  serialize: (val) => JSON.stringify(val, null, 2),
  parse: (text) => { try { return JSON.parse(text) } catch { return null } },
  onParseError: (err) => console.warn('Invalid JSON:', err.message),
})
// binding.dispose() on unmount

// Lazy-load a language grammar:
await loadLanguage('python')

// Tabbed editor — build an instance with createTabbedEditor, then mount it
// via the component's `instance` prop (each Tab uses `name`, not `label`):
const tabbed = createTabbedEditor({
  tabs: [
    { id: 'main', name: 'main.ts', language: 'typescript', value: 'export {}' },
    { id: 'styles', name: 'styles.css', language: 'css', value: 'body {}' },
  ],
})
tabbed.openTab({ id: 'readme', name: 'README.md', language: 'markdown', value: '# Hi' })
<TabbedEditor instance={tabbed} style="height: 500px" />

Exports

SymbolKindSummary
createEditorfunctionCreate a reactive editor instance.
bindEditorToSignalfunctionTwo-way binding between an editor instance and an external Signal<T> (or SignalLike<T>).
CodeEditorcomponentMount component for a createEditor instance.
DiffEditorcomponentDiff editor over @codemirror/merge.
createTabbedEditorfunctionCreate a reactive multi-file (tabbed) editor instance.
TabbedEditorcomponentMount component for a createTabbedEditor instance.
openSearchPanelfunctionOpen the find/replace panel on a mounted editor programmatically.
loadLanguagefunctionLazy-load a language grammar and return its CodeMirror Extension.
minimapExtensionfunctionCodeMirror extension that renders a canvas-based code overview minimap.
useEditorSignalfunctionComponent hook that two-way-binds an editor to a signal WITH automatic cleanup.
getAvailableLanguagesfunctionReturn every supported language identifier (the keys of the internal grammar-loader registry) — for building a language
darkTheme / lightTheme / resolveThemeconstantThe built-in editor themes.

API

createEditor function

(config: EditorConfig) => EditorInstance

Create a reactive editor instance. editor.value is a writable Signal<string> — editor.value() reads reactively, editor.value.set(next) writes back into CodeMirror. editor.cursor and editor.lineCount are computed signals. Config accepts value, language, theme, minimap, lineNumbers, foldGutter, readOnly (blocks user-input transactions, cursor stays), editable (live EditorView.editablefalse removes contenteditable entirely, a pure display surface; both are live signals on the instance), search (false omits the Mod-F keymap + selection-match highlighting; openSearchPanel(editor) is the programmatic escape hatch), onChange, onError (mount failures route here instead of an unhandled rejection), and more. The instance is framework-independent — mount it via <CodeEditor instance={editor} />.

Example

const editor = createEditor({
  value: '// hello',
  language: 'typescript',
  theme: 'dark',
  minimap: true,
  onChange: (next) => console.log('edit:', next),
})

editor.value()              // reactive read
editor.value.set('new')     // write into CodeMirror
editor.cursor()             // { line, col }
editor.lineCount()          // computed
editor.goToLine(42)
editor.insert('code')

<CodeEditor instance={editor} style="height: 400px" />

Common mistakes

  • Forgetting to declare @pyreon/runtime-dom in consumer app deps — <CodeEditor> JSX emits _tpl() which needs runtime-dom

  • Hand-rolling the applyingFromExternal/applyingFromEditor flag pattern — use bindEditorToSignal instead

  • Calling cursor-relative methods (insert / replaceSelection) before mount — the view is created by mount() after an async grammar load, so a pre-mount call has no cursor and is dropped (with a dev warning). Use editor.value.set(...) to set content independently of the view (it seeds the doc whenever the view is created)

  • Setting both vim: true and emacs: true — emacs wins

  • Relying on a thrown error to debug a broken setup (a throwing extension / failed grammar import) — mount failures no longer surface as an unhandled rejection; pass onError to observe them, otherwise they log a [Pyreon] message in dev. Disposing the editor while it is still mounting (a fast navigate-away during the async grammar load) is also leak-safe

See also: CodeEditor · bindEditorToSignal · loadLanguage


bindEditorToSignal function

<T>(options: BindEditorToSignalOptions<T>) => EditorBinding

Two-way binding between an editor instance and an external Signal<T> (or SignalLike<T>). Replaces the recurring loop-prevention flag-pair boilerplate. Round-trips through user-supplied serialize/parse functions. Internal flags break the format-on-input race; parse failures call onParseError and leave the external state at its last valid value. Returns { dispose } for cleanup.

Example

const data = signal<Doc>({ name: 'Alice', count: 1 })
const editor = createEditor({ value: JSON.stringify(data(), null, 2), language: 'json' })

const binding = bindEditorToSignal({
  editor,
  signal: data,
  serialize: (val) => JSON.stringify(val, null, 2),
  parse: (text) => { try { return JSON.parse(text) } catch { return null } },
  onParseError: (err) => console.warn(err.message),
})
// binding.dispose() on unmount

Common mistakes

  • Forgetting to call binding.dispose() on unmount — leaks both effects

  • Non-deterministic serialize() — if serialize(parse(text)) varies on each call, the helper dispatches redundant writes that fight the user's typing

  • Returning a non-null value from parse() for malformed input — return null on failure, or throw

  • Using bindEditorToSignal AND a manual editor.value.set() loop — defeats loop prevention

See also: createEditor


CodeEditor component

(props: CodeEditorProps) => VNodeChild

Mount component for a createEditor instance. Accepts instance, style, class, and passes through to a container div. Auto-mounts the CodeMirror view on render. Lifecycle is USER-OWNED — <CodeEditor> does NOT auto-dispose on unmount (the instance may be remounted, e.g. by <TabbedEditor> or a route revisit); call editor.dispose() yourself when the instance is done for good.

Example

<CodeEditor instance={editor} style="height: 400px" class="my-editor" />

Common mistakes

  • Expecting <CodeEditor> to dispose the instance on unmount — it does NOT (the instance is user-owned and may be remounted, e.g. by <TabbedEditor> or a route revisit). Call editor.dispose() from your own cleanup (onUnmount) when the instance is done for good, or the CodeMirror view leaks.

See also: createEditor · DiffEditor · TabbedEditor


DiffEditor component

(props: DiffEditorProps) => VNodeChild

Diff editor over @codemirror/merge. Accepts original and modified (strings OR Signal<string> — signal props update the diff reactively) plus optional language, theme, readOnly (default true), and inline. Default renders a side-by-side MergeView (two panes); inline: true renders a UNIFIED view — one editor showing the modified doc with the original as deleted-chunk widgets (per-chunk accept/reject controls appear when readOnly is false).

Example

<DiffEditor original="old code" modified="new code" language="typescript" />
// unified (inline) view — one pane, original shown as deleted chunks:
<DiffEditor original={originalSig} modified={modifiedSig} inline />

See also: CodeEditor · TabbedEditor


createTabbedEditor function

(config?: TabbedEditorConfig) => TabbedEditorInstance

Create a reactive multi-file (tabbed) editor instance. config is { tabs?, theme?, editorConfig? }tabs is an array of Tab ({ name, value, id?, language?, modified?, closable? }), editorConfig applies to every tab. The instance wraps a single underlying editor and exposes reactive tabs (Signal<Tab[]>), activeTab (Computed<Tab | null>), and activeTabId (Signal<string>), plus imperative openTab / closeTab / switchTab / renameTab / setModified / moveTab / getTab / closeAll / closeOthers / dispose. Mount it via <TabbedEditor instance={…} />.

Example

const tabbed = createTabbedEditor({
  tabs: [
    { id: 'main', name: 'main.ts', language: 'typescript', value: 'export {}' },
    { id: 'styles', name: 'styles.css', language: 'css', value: 'body {}' },
  ],
})
tabbed.openTab({ name: 'README.md', language: 'markdown', value: '# Hi' })
tabbed.switchTab('styles')
tabbed.activeTab()   // Computed<Tab | null>

<TabbedEditor instance={tabbed} style="height: 500px" />

Common mistakes

  • Passing tabs directly to <TabbedEditor> — the component takes an instance prop, not tabs. Build the instance with createTabbedEditor, then pass instance={tabbed}.

  • Using label for the tab title — a Tab uses name (the displayed file name); id is the optional unique key (defaults to name).

  • Forgetting to call instance.dispose() on unmount — it owns an underlying editor instance.

See also: TabbedEditor · createEditor


TabbedEditor component

(props: TabbedEditorProps) => VNodeChild

Mount component for a createTabbedEditor instance. Props are instance (REQUIRED — a TabbedEditorInstance), plus optional style and class. Renders a headless tab bar (plain div + button tabs) above the editor; switching tabs swaps the underlying document reactively.

Example

const tabbed = createTabbedEditor({ tabs: [{ name: 'a.ts', value: 'export {}' }] })
<TabbedEditor instance={tabbed} style="height: 500px" />

Common mistakes

  • Passing tabs={[…]} — there is no tabs prop; pass a createTabbedEditor instance via instance.

See also: createTabbedEditor · CodeEditor · DiffEditor


openSearchPanel function

(instance: EditorInstance) => boolean

Open the find/replace panel on a mounted editor programmatically. Works even when the editor was created with search: false (which only omits the Mod-F keymap + selection-match highlighting) — the deliberate escape hatch for apps that own their find-UI trigger. Returns true when the panel opened; pre-mount there is no view to host the panel, so the call is dropped with a dev warning and returns false.

Example

const editor = createEditor({ value: code, search: false })
<button onClick={() => openSearchPanel(editor)}>Find…</button>

Common mistakes

  • Calling it before <CodeEditor> has mounted — the view is created by mount() after an async grammar load, so a pre-mount call is dropped (dev warning, returns false). Trigger it from a user event or effect(() => { if (editor.view()) … }).

See also: createEditor


loadLanguage function

(language: EditorLanguage) => Promise<Extension>

Lazy-load a language grammar and return its CodeMirror Extension. All 19 non-plain identifiers ship a real grammar: json, typescript, javascript, jsx, tsx, python, css, html, markdown, rust, go, java, cpp, sql, xml, yaml, php from the modern @codemirror/lang-* packages, plus ruby and shell from @codemirror/legacy-modes (StreamLanguage). plain is intentionally empty. The result is cached per language; an uninstalled optional grammar package (or an unknown identifier) resolves to an empty [] extension (never throws). createEditor loads the grammar for its language on mount, so calling loadLanguage ahead of time just warms the cache.

Example

const ext = await loadLanguage('python') // the CodeMirror Extension
// createEditor({ language: 'python' }) now resolves instantly (cache warm)

See also: createEditor · getAvailableLanguages


minimapExtension function

() => Extension

CodeMirror extension that renders a canvas-based code overview minimap. Enable via createEditor({ minimap: true }) or add the extension manually to a CodeMirror state.

Example

const editor = createEditor({ value: longCode, minimap: true })
// or: import { minimapExtension } from '@pyreon/code'

See also: createEditor


useEditorSignal function

useEditorSignal<T>(options: BindEditorToSignalOptions<T>) => void

Component hook that two-way-binds an editor to a signal WITH automatic cleanup. It wraps bindEditorToSignal and registers onUnmount(() => binding.dispose()), so you do not manage the binding lifecycle yourself — unlike bindEditorToSignal, which returns a { dispose } handle for user-owned lifecycles. Options: { editor, signal, serialize, parse, onParseError? }signal is any SignalLike<T>, serialize projects the value into editor text, parse reads it back.

Example

function MyEditor() {
  const code = signal('console.log(1)')
  const editor = createEditor({ value: code(), language: 'javascript' })
  useEditorSignal({ editor, signal: code, serialize: (v) => v, parse: (t) => t })
  return <CodeEditor instance={editor} />
}

Common mistakes

  • Calling it outside a component — it relies on onUnmount for cleanup, so it only works during component setup. For a manually-managed lifecycle use bindEditorToSignal (which returns a { dispose } handle) and call dispose() yourself.

  • Expecting a return value — it returns void (no binding handle); disposal is automatic. If you need to tear the binding down early, use bindEditorToSignal instead.

  • A non-deterministic serialize/parse pair — the binding round-trips through both, so serialize(parse(serialize(x))) must equal serialize(x) or the editor and the signal fight each other.

See also: bindEditorToSignal · createEditor


getAvailableLanguages function

getAvailableLanguages() => EditorLanguage[]

Return every supported language identifier (the keys of the internal grammar-loader registry) — for building a language picker. Pairs with loadLanguage(id), which lazy-loads a grammar on demand. The set covers the bundled CodeMirror grammars plus 'plain' (no highlighting).

Example

const languages = getAvailableLanguages()
// → ['javascript', 'typescript', 'python', 'plain', …]

Common mistakes

  • Assuming a returned id is already loaded — getAvailableLanguages() lists what CAN be loaded; the grammar itself is lazy. Pass the id to createEditor({ language }) (or loadLanguage(id)) to actually load it.

See also: loadLanguage · createEditor


darkTheme / lightTheme / resolveTheme constant

darkTheme: Extension · lightTheme: Extension · resolveTheme(theme: EditorTheme) => Extension

The built-in editor themes. lightTheme and darkTheme are CodeMirror Extensions (a clean light palette and a VS-Code-inspired dark one). darkTheme carries the { dark: true } facet — the flag CodeMirror's dark-aware features AND this package's minimap key on (NOT a CSS class). resolveTheme(theme) maps 'light'/'dark' to those extensions and passes a custom Extension through unchanged (EditorTheme = 'light' | 'dark' | Extension). You normally set createEditor({ theme }) and let it resolve — reach for the raw extensions only when composing your own CodeMirror state.

Example

const editor = createEditor({ value: code, theme: 'dark' })   // resolved internally
// or compose the raw extension yourself:
const extensions = [darkTheme /* , ...other CM extensions */]

Common mistakes

  • Toggling dark mode by swapping a CSS class — CodeMirror keys its dark-aware behavior (and this package's minimap) on the EditorView.darkTheme FACET carried by darkTheme, not a class. Provide darkTheme (or theme: 'dark') so the facet is set.

  • Passing a theme NAME other than 'light'/'dark' to resolveTheme — only those two strings map to a preset; any other value must be a real CodeMirror Extension (it is returned as-is).

See also: createEditor · minimapExtension


Package-level notes

Peer dep: @pyreon/runtime-dom is required in consumer apps because <CodeEditor> JSX emits _tpl() / _bind() calls.

Note: editor.value is a writable Signal<string>. Read with editor.value() (reactive), write with editor.value.set(next) (pushes into CodeMirror). Do NOT call editor.value(newText) — that reads and ignores the argument.

Two-way binding: For external signal <-> editor synchronization, use bindEditorToSignal — it handles loop prevention, format-on-input races, and parse error recovery. Hand-rolling the flag pattern is the #1 source of bugs.

Bundle size: Built on CodeMirror 6. Measured (esbuild+gzip, code-split): the core editor is ~138 KB gz (~416 KB min) — at parity with @uiw/react-codemirror (~129 KB gz), both wrap the same CM6. Monaco's ESM core is ~940 KB gz (~3.6 MB min, workers/CSS excluded) — @pyreon/code is ~7x smaller gzipped. Each extra language grammar streams as a ~40 KB gz lazy chunk that reuses the loaded core. Reproduce with bun run --filter=@pyreon/code bench.

Third-party themes: EditorTheme is 'light' | 'dark' | Extension and resolveTheme passes a custom Extension through unchanged — so any @uiw/codemirror-theme-* package (dracula, github, tokyo-night, … an instant ~35-theme gallery) is a plain CM6 Extension that drops into theme: directly: createEditor({ theme: dracula }). Verified against a real @uiw theme package in the browser suite.

Lifecycle is user-owned: <CodeEditor> does NOT auto-dispose the instance on unmount — the instance is user-owned and may be remounted (TabbedEditor, route revisits). Call editor.dispose() yourself when the instance is done for good.

ruby / shell grammars: ruby and shell highlighting come from @codemirror/legacy-modes (an optionalDependency). It installs by default; if your package manager skips optional deps, those two fall back to plain-text (empty extension) rather than throwing.

Code Editor — API Reference