Markdown Fence Extensions
A MeshWeaver document is markdown, and some of its fenced blocks are alive: a ```csharp --render fence
runs and shows the control it produced, a ```layout fence embeds a live layout area, a
```mermaid fence draws a diagram. This page is about the seam that makes that possible, and about
the one thing everybody gets wrong on first contact with it: a fence is never rendered by the code
that parses it.
The seam, in one sentence
The platform parses a fence and emits an inert HTML marker; a client replaces that marker with something interactive.
The platform never renders an interactive fence. It emits a marker, and whichever client is on the other side decides what that marker becomes.
What lives where
| Piece | Home |
|---|---|
| Fence parsing + the marker's HTML | src/MeshWeaver.Markdown (this repository) |
| The controls a marker can resolve to | src/MeshWeaver.Layout (this repository) |
| Blazor hydration | MeshWeaver.Blazor/Components/MarkdownHtmlRenderer.cs (MeshWeaver.Plugins) |
| React hydration | clients/react/src/controls/interactiveMarkdown.ts (MeshWeaver.Plugins) |
| React Native hydration | the RN cell renderers (MeshWeaver.Plugins) |
Every renderer is in the other repository. That is the fact that decides the shape of any fence work: a new interactive fence is a two-repo change set, platform first, and the platform half is inert on its own by construction.
The existing markers
ExecutableCodeBlockRenderer and LayoutAreaMarkdownRenderer between them define the whole
vocabulary. There are only two shapes, and a new fence should reuse one rather than mint a third:
| Marker | Emitted for | What a client makes of it |
|---|---|---|
<div class='layout-area' data-address=… data-area=… data-id=…> |
```layout fences, and the result pane of every --render block |
A live layout area — the general-purpose escape hatch: anything expressible as a UiControl reaches every client through it |
md-code-cell / code-content / md-code-cell-toolbar (+ data-submission-id, data-language) |
an executable block that shows its code | The notebook cell: editor, output pane, Run bar on the bottom edge |
The layout-area marker is the powerful one. A fence that can be expressed as "render this
UiControl here" needs no client change at all — the clients already hydrate that div, and the
control travels through the normal layout-area machinery. It comes in two forms, from the same
builder (LayoutAreaMarkdownRenderer.GetLayoutAreaDiv / GetLayoutAreaDivOpenTag): empty, and
wrapping fallback content for a client that cannot hydrate it — the ```prompt fence uses
the second, and the degradation rule below is why.
🚨 The degradation rule
ExecutableCodeBlockRenderer states the contract for the cell markers outright: a client that does
not hydrate them "sees an ordinary div and keeps rendering the fence read-only — the attributes are
additive." Follow that rule for anything new.
A fence must never render as less than it did before the extension existed. The failure to avoid is a fence whose authored text disappears into a marker that one client turns into a rich widget and the others turn into nothing — the document is then worse on those clients than the plain fenced block it replaced, and nothing in CI can see it, because the platform's own tests only ever look at the marker.
Concretely: emit the authored content as ordinary markup as well as the marker, or wrap the
marker so an un-hydrated client still shows the text. The ```prompt fence below is the
worked example of the second — the marker carries the read-only fenced block as its children, which a
hydrating client drops on its way to mounting the live area.
🚨 Who may make a fence run
An executable fence (```csharp --execute / ```csharp --render X) runs in a kernel
the VIEWING user's view creates, under the VIEWER's identity — not the identity of whoever wrote the
text. That is the notebook feature: an author writes a page, every reader sees its cells run. It also
means that whether a fence runs cannot be left to the text. A markdown control shows plenty of text
the viewer did not write and its author may not be trusted to run code as that viewer: chat and
thread messages (including an agent's reply, which anything the agent read can steer), comments,
document summaries, descriptions, error messages.
So the decision belongs to the producer, and it is opt-in:
MarkdownControl.ExecutesCodedefaults tofalse. A non-executing control renders every executable fence as an ordinary code block — the code stays visible, there is no cell frame, no result area and no Run affordance, and a client finds nothing to submit. Submissions a producer attached are ignored.- A producer sets it (
WithExecutesCode()) only for notebook content: a node's own markdown body (OverviewLayoutArea), a content-collection markdown page (ContentLayoutArea), a Space body. Their authors are the people who may change that node. A markdown node's page is aCollaborativeMarkdownControland is a notebook by definition. - Every client asks the same pure decision,
MarkdownViewLogic.ResolveRendering(...), and renders non-executing markdown withMarkdownViewLogic.RenderInert(...)— the pipelineMarkdownExtensions.CreateInertMarkdownPipeline(collection, nodePath)carries the policy on its PARSER (ExecutableCodeBlockParser.ExecutesCode→ExecutableCodeBlock.ExecutesCode), so a document it parsed yields no submission to anyone — the renderer,ExtractCodeSubmissions, a repeatedInitialize()— before or after rendering. Pre-rendered HTML with no markdown to re-render loses its cell frame, Run toolbar and editor markers as well as its result areas. - A client that renders on its own (the React views) applies the same rule independently: it never hands a non-opted control's submissions to a kernel, and it refuses rendered HTML that still carries executable-cell markup in favour of the inert source.
Before this flag, the only thing keeping a comment's fence from running was render timing: the Blazor view submitted only on its first render, and a node-bound comment's text happened to arrive after it. Text that was present at first render — every message of a thread opened after the fact — ran. Timing is not a permission, which is why the decision moved to the producer.
Worked example: the prompt fence (#2511)
Course pages author suggested AI prompts as ```prompt fences. They used to render as static
fenced code: readable, but not editable and not runnable. The request was that such a fence become a
composer pre-filled with the authored text, whose Submit starts a real agent thread and opens it
full page.
Walking it through the seam gives the whole change set — and shows why the interesting half is not in this repository.
The platform half (this repository)
- Parse.
ExecutableCodeBlock.InitializederivesPromptDraftfrom the fence body whenever the info string isprompt, next to thelayoutblock it already parses. A prompt fence never produces aSubmitCodeRequest— it is prose for an agent, not source for the kernel, and saying so inGetSubmitCodeRequestmeans a stray--renderon one cannot turn it into a code cell. - Emit.
ExecutableCodeBlockRenderer.WritePromptComposerlowers it to the layout-area marker rather than a new one, pointing at thePromptarea on the page node's own hub. Nothing new has to be taught to any client for the composer to appear. - Carry the draft. The authored text rides as the area's reference id, base64url-encoded
(
PromptFence.EncodeDraft). Not raw: an area id is concatenated into hrefs, and everything after a?in one is parsed as reference parameters — and a prompt is prose, full of/,?,&and newlines. This is the same encoding, for the same reason, asLayoutAreaReference.GetMeshNodeDataContext. - The control.
MeshNodeLayoutAreas.PromptComposerreturns the composer that already exists —ThreadChatControl, the same control the side panel and the Threads app mount — with the decoded draft on its newInitialDraftproperty andHideEmptyStateon. That flag is load-bearing:ThreadChatViewreads it asisCompactand navigates to the created thread full page instead of handing it to the side panel. "Submit starts a full-page thread" is that flag. - Degrade. The marker wraps the ordinary read-only fenced block. A client that hydrates layout areas replaces the div and drops its children; one that does not renders them — the authored prompt, exactly as it read before. With no owning node there is no hub to serve the area, so the fence stays a plain block rather than emitting an ownerless address.
The client half (MeshWeaver.Plugins)
ThreadChatView must seed its composer from ThreadChatControl.InitialDraft — one-shot, into a NEW
chat only, so a draft can never clobber text the user is already typing. The machinery is there
already: SeedPendingDraftIfAny does exactly this job from a different source (the side panel's
one-shot PendingComposerDraft, the "new thread from this cell" hand-off), and the declarative draft
is the second source feeding it.
Starting the thread needs nothing new: Hub.StartThread and the full-page navigation are what
ThreadChatView's submit already does in compact mode.
The React client hydrates layout-area markers with a regex that matches an empty div
(interactiveMarkdown.ts), so a wrapped marker falls through to the fallback there and shows the
prompt read-only — correct by the degradation rule, and a one-line widening away from the composer
when that client wants it.
Verification
Each half is asserted where its subject lives, which for a fence means two repositories:
| Suite | Repo | What it asserts |
|---|---|---|
MeshWeaver.Markdown.Test/PromptFenceComposerTest |
MeshWeaver.Plugins |
the fence produces the expected marker, the draft round-trips through the area id, the marker wraps the read-only fence, no owner ⇒ a plain block, and the fence never reaches the kernel |
MeshWeaver.Graph.Test/PromptComposerAreaTest |
this repo | the Prompt area produces a compact ThreadChatControl carrying the authored text |
🚨 MeshWeaver.Markdown is compiled here but TESTED in MeshWeaver.Plugins — that repo owns
MeshWeaver.Markdown.Test (it project-references this one through $(MeshWeaverRoot)), and this
repo has no test project referencing MeshWeaver.Markdown at all. A rendering assertion written
here has no home; it goes in the markdown suite, over there. The corollary is that a green platform
build proves nothing at all about a fence's HTML — the run that does is the other repo's.
And the half that a learner can actually see is in neither — there is no Blazor in this repository —
so the acceptance check is a rendered page: open a course lesson that ships a ```prompt
fence, confirm the composer shows the authored text, edit it, submit, and land on the full-page
thread.
Related
- Interactive Markdown — the author-facing fence dialect.
- Authoring Documentation — fence rules for doc pages specifically.
- User Interface — how a
UiControlreaches a client at all. - Repository Dependency Direction — why the platform never reaches into a plugin repo.
- Thread Operations — what starting a thread actually does.