CompMD Developer's Guide. Written for the @compmd packages at 0.5.0, and following them as they change. Download the book as PDF

3. How it fits together

The quick start used four packages in a few lines. This chapter is what each does, where it runs, and why it is split that way, so the parts after it make sense.

The processor: text in, trees out#

@compmd/processor takes a request, a document and everything it needs, and gives back what a page needs. It runs in stages, each taking only what it uses:

Stage Needs Gives
Parse the document's text the parse tree; syntax diagnostics
Resolve the files it imports, its packs every component name bound to a definition
Validate signatures params checked and typed
Load its data, the inputs' values the data
Evaluate all of the above, and the time the expanded tree: content only, ready to render

The processor does no I/O. Your application puts everything in the request. That's why it runs the same in a browser, a worker, a build or natively, and why opening a document never makes it fetch anything on its own. Only path and now are required:

Field Holds The stage that reads it
path the document's path in files all
files the files of the document's folder by path: the document, what it imports, its data files parse, resolve, load
packs the packs by name, each with its releases and their files resolve
ambient packs every document gets without importing them resolve
policy your packs' trust, which packs a document may import, which are denied resolve
allowedOrigins the origins remote data may come from validate
remote remote data, fetched beforehand, by URL load
host your app's own data, for host: sources load
inputs the inputs' values, by name load, evaluate
now, tz the time the page is made, RFC 3339, and the time zone of a document without one evaluate
origins whether expanded nodes say where in the text they come from, as a live preview needs evaluate

virtual:compmd's request fills packs, ambient and policy from your configuration; the rest is the document's. sources(request) lists what a render needs that the request lacks, remote URLs, host: sources and packs, so you can get them first. Processing documents and data and inputs go through them.

Parsing needs the text alone, so an editor or a linter can parse any document, even one whose packs and data it doesn't have.

process(request) runs every stage once. OpenDocument keeps the document open: each edit processes only the blocks it touches, and gives a splice, what changed in the expanded tree, for an editor's live preview.

Diagnostics#

Every stage reports what it finds wrong with the document as diagnostics, and none stops the others: a broken component renders its fallback, and the rest of the page is there. A request that isn't valid is the only thing that throws. Each diagnostic is the spec's machine form:

Field Is
code F… for an error, which changes what renders; W… for a warning, which doesn't
severity error or warning, from the code
message a sentence for the writer; not stable, so match on code
range where: 1-based lines and columns, in code points
file the file it is in, when it isn't the processed document, such as a pack's document
related other places it involves, such as the opener of a container never closed
stage the stage that found it: parse, resolve, validate, load or evaluate
fix when there is one, the edit that fixes it, for a quick fix

The processor gives them in stage order, with its result. Two come later, from the sandbox, since only running a component's code finds them: W118 for a component that threw or failed, and W121 for code that can't load (the processor reports W121 itself only for a pack whose peerDependencies your frames can't meet). They arrive through the view's error event, view.on("error", ({ component, code, message }) => …). The diagnostics appendix lists every code.

The expanded tree, and the components#

The expanded tree is content: headings, paragraphs, lists, boxes, input components, and the components written in code, by name and params. It holds no code. The code of each component comes beside it, in components: a fence's or a file's text to compile, or a built module's place in its pack, with the trust its pack is given.

The renderer and the sandbox: trees in, a page out#

@compmd/render turns a tree into a live page and updates it in place: a new tree, or a splice, changes only what changed, and components keep their state.

@compmd/sandbox decides where each component's code runs, by the trust its pack is given:

Trust Where it runs What it can reach Who
strict (the default) a frame with an opaque origin and no network its params, its slots, its box any pack, any document
permissive a frame of its own that, plus the network to origins you list, and host if you give it packs your configuration lists
trusted your page what your page can your app's own components, built with it

A document can never raise a pack's trust: only your configuration can. So a document from anyone, and any pack it imports, can't reach your page, your session or your data.

The sandbox renders a document in one of two modes:

  • Frame mode (the default, as in the quick start): the whole page in one strict frame. Simple, and right for documents from anyone.
  • Host mode: the page in your page, your trusted components inline, every other component in a frame of its own. Right for an app with components of its own.
Frame mode, page: "frame" Host mode, page: "host"
The page one strict frame per document your page
Strict components in the page's frame a frame per use
Permissive components their fallbacks, with an error not-here a frame per use, with host if your configuration gives it
Trusted components their fallbacks, with an error not-here inline, in your page

Frames only go down in trust: your page holds permissive and strict frames, a permissive frame holds strict ones, and a strict frame holds none. So a permissive or trusted component in the slot of a strict container shows its fallback too. Which level each component gets comes with it from the processor, in components, set by your configuration; the sandbox only carries it out. Components and trust and the sandbox, closer say more.

The plugin: your app's side#

@compmd/vite does at build time what every app would otherwise write:

  • it reads compmd.config.json: which packs get more than strict trust, which are ambient (every document gets their names), which are denied;
  • it finds your packs, in node_modules and in folders of your app, and gives them to the processor as request.packs;
  • it serves the sandbox's frame documents and the packs' built modules from your app, under compmd/, with the headers the frames need;
  • it builds your trusted components with your app, and gives them to the sandbox as sandbox.trusted.

virtual:compmd hands both to your code, request for the processor and sandbox for the sandbox, so you spread them in and write no policy, path or import map yourself.

Why it is split this way#

  • The processor knows nothing of the browser, so it can check documents in CI, render them at build time, or run in a native app.
  • The renderer knows nothing of trust, so a host that renders documents it trusts completely can use it alone.
  • The sandbox carries the security, in one place, tested against every threat it guards from, so no application writes a frame or a content security policy itself.
  • The plugin is the only part that knows your build, and other hosts can do its job by hand, as other hosts shows.

Next: Part II, processing documents.