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#
| Size | When it loads | |
|---|---|---|
| The editor | ~1.1 MB | on mount |
Maths ($…$, $$…$$) | 207 KB + 21 KB | the first document containing a formula |
Diagrams (```mermaid) | 3.4 MB, split across ~100 files | the 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:
| pin | asks | fails when |
|---|---|---|
minPlatform | does this platform have the atoms the kit's source names? | the kit composes over a renderer primitive you do not have |
contractVersion | can 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.
| File | What it is |
|---|---|
markdown.osy | The control declaration — props, events, commands, chrome slots, chunks. This is what your app compiles. |
markdown.ts | The shim: the editor itself. Yours to edit. |
markdown-theme.css | Its stylesheet, in terms of your theme's tokens. |
math.ts | The maths document model — how $…$ parses and serializes. |
diagrams.ts | The diagram node view — how a ```mermaid fence is drawn. |
section-map.ts | Section identity: which edit belongs to which stored section. |
styles.d.ts | One 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.mjs | Builds the maths and diagram chunk assets from your node_modules. |
package.json | The build-time dependencies. None of them are runtime dependencies of your app. |
package-lock.json | The 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.json | Type-checking only — the bundler strips types without looking at them, so this is what makes the shim answer to its generated ABI. |
README.md | How 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 appNothing 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:
| Prop | What it does |
|---|---|
readOnly | Renders the document without editing affordances. |
recordScoped | Edits 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, density | The reading typeface and how much air the document gets. |
outline | A 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, replaceWith | The 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#
- Markdown — rendering markdown text — display markdown with no dependencies, when nobody is editing
- chunks — assets a control loads on demand — how the optional maths and diagram halves are loaded
- control — foreign UI controls (charts, grids, maps) — declaring a foreign control of your own
- commands — the verbs a control accepts — the chrome-slot mechanism the toolbar uses
- [FrontMatter] — a document's header as typed data — binding a document's
---header to entity members