# Architecture MarkdownBlaze is a native window (PhotinoX, which hosts the operating system's own web view) running a Blazor UI built with Microsoft Fluent UI, on .NET 10. A document is turned into HTML by Markdig on the .NET side, cleaned by an allow-list sanitizer, and shown in the page; a small amount of JavaScript (the ES modules in `src/MarkdownBlaze/wwwroot/js/`) does what only the browser can do: measure, scroll, print, and draw code, formulas and diagrams. The page is served from the app's own `markdown://` scheme, and everything it needs to render is bundled, so nothing is fetched at start-up. This page records the three rules the code is built around, and the trust boundary. The comments in the source explain each rule where it bites; this is the overview to read first. ## Three invariants ### 1. Blazor owns the document element; renderers change what is there, never what is in the tree The rendered Markdown is one element (`.content.markdown-body` in `ViewerView.razor`) whose children Blazor has put there as a `MarkupString`. highlight.js, KaTeX and Mermaid then write into those children (coloured spans, math markup, an SVG replacing a `
`), and the pagination code adjusts them. Blazor keeps a virtual render tree that describes the DOM it believes exists. Inserting nodes among its children, or removing them, leaves that tree describing a DOM that no longer exists, and the next diff blanks the document. So: - the document element carries an `@key` that changes on every navigation, which makes Blazor discard and rebuild the whole subtree instead of diffing it against markup the renderers have edited; - renderers and layout code **mutate in place** (change attributes, text, styles and classes of the nodes that are there) and **never add or remove nodes** in the document's tree. When something has to appear (a page gap, a marker), it is painted with CSS or marked with a `data-` attribute on an existing node; - elements Blazor owns elsewhere (the sidebar rows, for example) are marked with data attributes, not classes, for the same reason. ### 2. The document's theme is separate from the app's theme There are two independent looks. The **app theme** (Light, Dark or System; Fluent UI tokens) styles the chrome: toolbar, sidebar, dialogs, settings. The **document theme** (eleven of them, plus "Follow app theme") styles only the document: paper colour, typeface, size and spacing. A document theme is a set of `--ct-*` CSS tokens selected by a `ct-` class on ``, together with a `ctl` / `ctd` marker that says whether the document reads light or dark. The shared structure in `wwwroot/css/content-themes.css` reads those tokens, so a rule that must hold for every theme (one typeface, one body size, one heading scale, outlined boxes) holds by construction. The marker, not the app theme, decides the highlight.js palette (`highlight-content.css`) and the Mermaid theme, so code and diagrams match the page they are on. Neither theme reads the other: a dark app can show a white Book-theme page, and the other way round. Printing uses the print bar's own choice of theme for that one print. ### 3. Pagination is CSS-only Page view lays the document onto A4 sheets, the way it will print, without any page elements. The document element is given the width of a sheet; a repeating `linear-gradient` on that same element paints the paper, its edges and the gaps between sheets; a CSS `drop-shadow` follows the gradient's alpha so each sheet has its own shadow. JavaScript only **measures** the flow and sets a `margin-top` on a block that would otherwise straddle a gap, pushing it to the next sheet. It adds no nodes (invariant 1), and it drops back to the plain flow before printing, because the printer paginates by itself with `@media print` rules (`break-inside: avoid` on figures, tables, code and callouts). Fit-to-width and whole-page zoom are a single `--doc-zoom` value held on the pane. ## Threading model **The UI thread owns the state.** `NavigationService` (the current document, the back/forward stack) and `ViewerState` (zoom, view, popups, the sidebar's rows) are single-threaded by construction: they are not locked, every member is called on the Blazor renderer's thread, and every event they raise is raised there. Debug builds assert it (`Debug.Assert(CheckAccess())`), so a mutation from the wrong thread fails at once instead of corrupting the render tree later. The work that happens elsewhere is handed back through `IUiDispatcher` (`Services/IUiDispatcher.cs`): `CheckAccess()`, and `InvokeAsync` for an action or an async function. `ViewerView` implements it with its own `InvokeAsync` — the renderer's dispatcher — and attaches itself to `NavigationService` and `ViewerState` when it is created; until then (and in tests) the default runs the work where it is called. Three kinds of thread feed the UI thread this way: - **the render itself**, on a thread of its own (`Services/RenderWorker.cs`: a background thread with a 64 MB stack, made for each render, because the libraries that parse a document recurse once per level of nesting and a stack overflow cannot be caught): a document is read, parsed and sanitized off the UI thread, and only the finished result is applied on it. A newer navigation, reload or drop cancels the render in flight, and a superseded result is never applied. A render that is still going after 30 seconds (`NavigationService.RenderDeadline`) is given up on — the page says so, naming the file, and the result is never applied. Its thread cannot be stopped, so it runs on until it next looks at its cancellation token, and is logged at Warning when it ends; while two such threads are still running, a new document is not started (the page says "Still rendering an earlier document"); - **timers**: the file watcher's debounce (a reload), the toast, the debounced saves of the preferences and the history (these only write files); - **callbacks from the page**: the JavaScript interop calls the instance methods of `ViewerInterop`, which `PageEvents` turns into a command or a navigation on the UI thread. Work that is started and not awaited goes through `Background.Run` / `Background.Observe` (`Services/Background.cs`), which write the failure of a discarded task to the log instead of losing it. ## How a document gets to the screen 1. **Ask.** A click, a history row, a key, a drop or the command line becomes `NavigationService.NavigateAsync` (or `ReloadAsync` when the file watcher fires). It updates the back/forward stack at once, starts watching the file, and starts the render on a render thread (with a 30-second deadline), cancelling the one before it. 2. **Read.** `MarkdownService.RenderFile` opens the file with a share mode that tolerates an editor's lock and refuses anything over 32 MB. 3. **Parse.** Markdig parses it with the `bwets.Markdig.Extensions` pipeline (front matter, callouts, wiki links, math, tags, …), which gives up on Markdown nested more than 128 levels deep (`MarkdownService.MaxMarkdownNesting`; the page then says the document is nested too deeply). The title, the headings and the document's language are taken from the tree. 4. **Render** the tree to HTML. 5. **Parse the HTML, and cut it down.** The HTML — Markdig's, with whatever raw HTML the author wrote in it — is parsed once with AngleSharp, whose parser is iterative, and `NestingLimit` replaces everything deeper than 512 levels (Chromium's own limit) with its text, walking the tree with a stack of its own. This comes before anything else touches the tree, because the sanitizer's walk recurses once per level. 6. **Sanitize.** The tree goes through the allow-list in `HtmlSanitizer`, which removes everything an author could have used to run code — including any `data-nav` or `mdasset:` address the author wrote — and every class, id and role that the app's own markup uses. 7. **Resolve links and pictures.** `ReferenceResolver` walks the sanitized tree once, for Markdown's links and pictures and the author's raw `` and ` ` alike: a relative address to a Markdown document becomes an in-app link (`data-nav`), an address that names a local file in any other way is made inert, and a local picture that `LocalPath` allows gets its `mdasset://doc/
/…` address. It runs after the sanitizer and nothing runs between them, so the only `data-nav` and `mdasset:` on the page are the ones it wrote. At most 5,000 links and 2,000 pictures are looked at on disk per document; the rest are inert, or a placeholder. The tree is then serialized, once. 8. **Apply, on the UI thread.** `NavigationService.Apply` takes the result through the dispatcher: it sets the current HTML, title, headings and language, hands the image grant to `AssetScope` (so the handler serves this render's pictures and no other's), records the history entry, bumps `RenderToken` and raises `Changed`. 9. **Draw.** `ViewerView` re-renders; the document element is `@key`ed on the token, so Blazor rebuilds it from the new markup (invariant 1). In `OnAfterRenderAsync` it sees the new token and calls the page's `renderDocument` through `ViewerInterop`. 10. **Finish in the page.** `renderDocument` (JavaScript) lays the document out and marks the outline at once, then highlights code, typesets math and draws diagrams in place, and lays the pages out again when those are done and the fonts are ready. The WebView fetches the pictures from the `mdasset://` handler as it draws them. ## The trust boundary **A Markdown file is untrusted input, and so is the page that renders it.** MarkdownBlaze is the handler for `.md` files and for `markdown://` addresses, so any downloaded file, colleague's wiki page or web link can drive it. What stands between a hostile document and the user's machine is a set of independent layers, each assuming the one before it failed. **The sanitizer** (`Security/HtmlSanitizer.cs`, built on HtmlSanitizer and AngleSharp). Markdig passes raw HTML through, so the rendered body is run through an allow-list before it reaches Blazor, in the one place every rendering path goes through (`MarkdownService.RenderCore`: files, dropped text, What's new). Only the tags Markdig and its extensions emit, plus the harmless HTML authors write in Markdown (`details`, `kbd`, tables, task-list checkboxes), survive. Scripts, frames, forms, ``, ` `, `