Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

The markdown editor kit — a rich editor you opt into

MarkdownEditor(ownerType: "Article", ownerId: a.Id, property: "Body")

A full rich-text markdown editor — sections, partial saves, a block menu, tables, find and replace, maths and diagrams — shipped as an optional KIT you depend on with one line, not as a platform feature every app carries. This page says what you get, what it costs, why it is a kit, and the two lines that get it running.

stable1 example compiled by CIuicontrolsmarkdownauthoring

Summary#

The platform ships mechanism, never components: a way to declare a foreign control, a way to load its assets on demand, a way for it to read your theme. It ships no editor by default. The markdown editor kit is a complete one built on that mechanism, distributed as an ordinary kit — you depend on it in one line and nothing is copied into your project:

app Notes {
  use Osyrin.Markdown@1;    // the editor
  model "model/**/*.osy";
}

That is the architecture, not a limitation. The client runtime has zero runtime dependencies, deliberately. An editor is 1.1 MB of somebody else's library choices; a diagram engine is 3.4 MB more. Those belong to the apps that asked for them — pinned by content hash in your lock file, resolved offline, and costing nothing at all to an app that never writes the use.

⚠️ Most pages do not want this. If you only need to display markdown, use Markdown — rendering markdown text — it is in the platform, has no dependencies, needs no use, and renders identically on the server and the client. It renders neither maths nor diagrams. Reach for the kit when a person is going to write into the document.

Signature#

With the dependency declared, the editor is an ordinary control at a call site:

MarkdownEditor(
  ownerType: "Article",     // the entity that owns the document
  ownerId: article.Id,      // which row
  property: "Body"          // the Markdown property on it
)

Description#

What you get, and what it costs#

SizeWhen it loads
The editor~1.1 MBon mount
Maths ($…$, $$…$$)207 KB + 21 KBthe first document containing a formula
Diagrams (```mermaid)3.4 MB, split across ~100 filesthe first document containing a diagram — and then only the parts that diagram needs

The two optional halves are chunks — assets a control loads on demand, so those numbers are what a document that uses them costs. Measured on the reference app: a document of prose makes one asset request; adding a formula makes two more; adding a state diagram fetches an entry plus the handful of engine parts that one diagram type reaches — well under a quarter of the 3.4 MB on disk.

What the editor does: a rendered view and a source view, section identity that survives arbitrary edits, partial saves with per-section preconditions, conflict recovery, a block handle and slash menu, an outline rail, tables with resizable columns, code highlighting, a selection toolbar, a link popover, find and replace, footnotes, private images, maths and diagrams.

Why it is a kit rather than a platform feature#

Three reasons, in the order they will matter to you.

The client has no runtime dependencies. Everything the platform's own runtime does, it does with its own code. Building an editor into it would add ProseMirror, CodeMirror and their trees to every app on the platform, including the ones that render a dashboard. As a kit, the cost lands only where the use is written.

It is built on the public contract, and that is checked. The shim imports its own siblings, its generated ABI typings and third-party libraries — nothing from platform internals — and the build proves it: a generated conformance file makes the type-checker hold mount to the declaration. So the kit is evidence that the foreign-control contract is sufficient, rather than a claim about it. Yours can do everything it does.

Different apps want different editors. A knowledge base and a comment box want different affordances; the kit is one good answer, not the only one. It is app-forkable like every kit — take it, change it, and your copy wins.

Where it lives, and how it is pinned#

The kit is published at github:osysharp/markdown-kit, and a copy ships with the platform, so use Osyrin.Markdown@1; resolves offline — no network, no node_modules, nothing to install.

osyrin.lock records what you resolved to, and a control kit pins two independent compatibility facts:

pinasksfails when
minPlatformdoes this platform have the atoms the kit's source names?the kit composes over a renderer primitive you do not have
contractVersioncan this platform host the kit's control ABI?your client's supported-contract set excludes it

They are separate because a kit can satisfy one and fail the other — this kit targets contract 1.1 and names no new atom at all. Each is checked at resolve, with its own message, so a failure tells you which of the two you have.

The files, and what each is for#

You need none of this to USE the kit — it is what the kit is made of, for anyone reading it or forking it.

FileWhat it is
markdown.osyThe control declaration — props, events, commands, chrome slots, chunks. This is what your app compiles.
markdown.tsThe shim: the editor itself. Yours to edit.
markdown-theme.cssIts stylesheet, in terms of your theme's tokens.
math.tsThe maths document model — how $…$ parses and serializes.
diagrams.tsThe diagram node view — how a ```mermaid fence is drawn.
section-map.tsSection identity: which edit belongs to which stored section.
styles.d.tsOne line that tells the type-checker a .css import arrives as a string. Easy to leave behind, and without it the shim does not type-check.
scripts/build-chunks.mjsBuilds the maths and diagram chunk assets from your node_modules.
package.jsonThe build-time dependencies. None of them are runtime dependencies of your app.
package-lock.jsonThe EXACT versions those dependencies resolved to. Copy it with package.json: a kit that keeps its lock rebuilds the same bundle, and one that drops it picks up whatever the registry offers that day — which is a different bundle from the one that was tested, with nothing to say so.
tsconfig.jsonType-checking only — the bundler strips types without looking at them, so this is what makes the shim answer to its generated ABI.
README.mdHow to build the kit and what each part is, for whoever picks it up next.

From an empty app to a working editor — the sequence#

From an empty app to a working editor, in full:

// app.osy
app Notes {
  use Osyrin.Markdown@1;
  model "model/**/*.osy";
}
// the page that uses it
using Osyrin.Markdown;
osy compile      # resolves the pin, ships the kit's bundle and chunks, compiles the app

Nothing is copied into your project and there is no JavaScript toolchain in the loop: the declaration arrives with the use, and the bundle rides the compile from the platform's own kit cache. osy lock writes the pin; osyrin update markdown moves it forward within the major you declared.

Forking the kit to change how it behaves#

The kit is app-forkable, which is the whole point of the tier. Take the shim, put it in your own project, register it with osy control add, and your control MarkdownEditor shadows the kit's by name everywhere — including inside anything that renders it. From then on it is ordinary app code: osy control build re-bundles and re-pins it after every edit to the shim, and nothing in the platform has an opinion about what you changed.

What the app passes in — a document address, not text#

The editor takes the address of a document, not its text. A document is a section-structured thing the editor rewrites continuously; passing its contents as a prop would mean re-marshalling the whole document on every keystroke.

// ⚠ This example DECLARES the control inline, because a documentation example compiles on its own with no manifest
// to carry a `use`. In your app you write `use Osyrin.Markdown@1;` instead and delete this block — the declaration
// arrives with the kit. What follows is `markdown.osy`, trimmed to what this page uses.
control MarkdownEditor {
  contractVersion "1.1"
  participation headless
  props {
    string ownerType;
    Guid ownerId;
    string property;
    bool recordScoped = false;
    [Values(comfortable, compact)] string density = "comfortable";
    bool outline = false;
  }
  chunks { Math; MathCss; Diagrams; }
}

entity Article {
  [MaxLength(200)] string Title;
  Markdown Body;
}

[Page("/articles/{id}")] [AllowAnonymous]
component ArticlePage(Guid id) {
  var article = Article.Where(a => a.Id == id).First();
  render {
    Stack {
      Text(article.Title);
      MarkdownEditor(ownerType: "Article", ownerId: article.Id, property: "Body", outline: true);
    }
  }
}

Props worth knowing:

PropWhat it does
readOnlyRenders the document without editing affordances.
recordScopedEdits join the page's unit of work and land when the record commits. Default false: the document keeps its own lifetime, which is what a standalone document — and any agent writing to it — wants.
face, densityThe reading typeface and how much air the document gets.
outlineA heading outline down the side. Worth the width on a long document only, which is something the page knows and the control does not.
findQuery, replaceWithThe find engine's state. The editor ships no find bar — it holds the engine and raises findRequested, and the bar is yours to design.

A document's --- front-matter binds to ordinary queryable members — see [FrontMatter] — a document's header as typed data.

The editor also declares a Toolbar chrome slot (commands — the verbs a control accepts): your own content, rendered inside the editor's toolbar, handed the editor's commands so your buttons can drive it.

The theme tokens it reads#

The editor inherits your app's look. It reads your base tokens — Colors.Surface, Colors.OnSurface, Colors.Border, Colors.Muted, Colors.Primary, and the Fonts family — and a nested Markdown group for the document's own typography, so a document can differ from the rest of the app without either being hard-coded:

theme {
  Colors { Markdown { Link; Quote; CodeBg; } }
  Fonts  { Markdown { Body; Heading; Mono; } }
}

⚠️ A theme leaf name is global. A nested group namespaces the CSS variable, not the name — Fonts { Markdown { Heading } } and the Fonts { Heading } most apps already have are the same leaf. Name them so they do not collide.

Diagrams are themed from these same tokens, with one wrinkle worth knowing: a diagram engine bakes its colours into the picture when it draws it, so switching to dark mode re-renders rather than re-styles. The kit watches for that and redraws.

Traps that cost real time#

The bundle is a build artifact. This matters only if you FORKED the kit: editing markdown.ts changes nothing until you re-bundle, and the hash changes when you do, so it must be re-registered — osy control build does both. Using the kit as a dependency, there is nothing to build.

Rename a component, restart the dev server with --reset. A stale instance keeps the old component bound to its route, and compile reports success.

A single-file chunk must be self-contained; a package need not be. This is the distinction that decides how you register something — see chunks — assets a control loads on demand.

Register your own build output, not node_modules. A published package's distribution directory carries every build flavour and its type definitions; the diagram library's is 83 MB across 1167 files. The kit's build script does this correctly — copy its approach if you add a chunk of your own.

Examples#

An article editor with the app's own find bar in the editor's toolbar:

[Page("/articles/{id}")]
component ArticlePage(Guid id) {
  var article = Article.Where(a => a.Id == id).First();
  var find = "";

  render {
    MarkdownEditor(
      ownerType: "Article",
      ownerId: article.Id,
      property: "Body",
      outline: true,
      findQuery: find
    ) {
      slot Toolbar { c =>
        Row {
          TextInput(value: find, placeholder: "Find");
          Button("Next", onPress: c.FindNext);
          Button("Previous", onPress: c.FindPrev);
        }
      }
    }
  }
}

A comment box wants the opposite settings — compact, no outline, and scoped to the record so the comment and its body commit together:

MarkdownEditor(
  ownerType: "Comment",
  ownerId: comment.Id,
  property: "Text",
  density: compact,
  recordScoped: true
)

See also#

Related

control — foreign UI controls (charts, grids, maps)

A `control` block declares the contract of a foreign UI widget — a chart, a data grid, a map — that a small JavaScript…

chunks — assets a control loads on demand

A `chunks { }` block declares assets a control ships but does not need at mount — a maths renderer, a diagram engine, a…

styles — a control's own look knobs

A `styles { }` block declares the look values a control owns — its paddings, widths, shadows — as named knobs an app…

commands — the verbs a control accepts

A `commands { }` block declares the verbs a control accepts — the mirror of its events. An event is the control telling…

Markdown — rendering markdown text

`Markdown(text)` renders a markdown string as formatted content — headings, lists, tables, code, links. It is a…

[FrontMatter] — a document's header as typed data

`[FrontMatter]` binds a member to a key of the entity's markdown document's front-matter — the `---` header at the top…

web fonts — shipping a typeface with your app

Naming a font in your theme asks for it; `osy font add` ships it. The command pins a font file in your project's lock…