@osionos/markdown-engine — a compact Markdown engine built around a canonical src/
implementation.
- Markdown parsing into a typed AST
- Semantic HTML rendering
- Incremental reparsing from line-range patches
- Diagnostics for malformed or out-of-bounds input
The engine never imports a host application. It declares its own block vocabulary in
blockContract.ts (MarkEngineBlock / MarkEngineBlockType) and its own
table-layout rules in tableConfig.ts. A host adapts to the engine, not the
reverse — which is what makes it reusable across apps.
MarkEngineBlock carries a [key: string]: unknown index signature, so a host with a richer
block model stays structurally compatible without a cast. Hosts are expected to add one
compile-time guard asserting MarkEngineBlockType extends <their BlockType>; then adding a block
type here that the host does not know fails that single file instead of thirty call sites.
Consuming apps enforce the boundary with two gates on their side:
- a
tscproject that clearspaths, so any@/…import is a hard resolution error; - a lexical check for host-package imports,
../../../escapes, andreactoutside the React entry point (a real package still resolves, sotscalone cannot catch it).
The root entry point is React-free and DOM-free. That is load-bearing, not cosmetic: it must
stay loadable by node --test --experimental-strip-types.
| Subpath | Contents | Needs |
|---|---|---|
. |
AST types, parse/parseInline, renderHtml, parseInlineMarkdown, render modes, worker clients, URL sugar |
— |
./blocks |
detectBlockType, BLOCK_SHORTCUTS, parseMarkdownToBlocks, the block contract |
— |
./inline |
inline formatting, text edits, autoformat, inline document, colour tokens | — |
./dom |
contentEditable selection/DOM helpers |
lib.dom |
./react |
MarkdownView, renderReact, renderInlineToReact |
react (peer) |
./tables |
table normalization and config | — |
./terminal |
ANSI renderer, callout icons | — |
There is no build step for consumers: exports points at TypeScript source, resolved through the
host's bundler/compiler alias rather than node_modules. npm run build:engine exists only for
this repo's own tests, coverage and benchmarks.
tsconfig.jsondeliberately covers only the pure core (lib: ["ES2022"], no DOM) — it is the standing proof that the parser/renderer half needs neither a DOM nor React. The./domand./reactentry points are type-checked by the consuming app instead.
The current entry point for the rich inline/editor helpers is index.ts.
Typical usage:
import {
compileMarkdownToHtml,
incrementalParse,
parseMarkdown,
renderHtml,
} from ".";
const parsed = parseMarkdown("# Title\n\nA *fast* engine.", {
documentVersion: 1,
});
const html = renderHtml(parsed.ast);
const compiled = compileMarkdownToHtml("# Title\n\nA *fast* engine.");
const next = incrementalParse("# Title\n\nA *fast* engine.", parsed, {
fromLine: 2,
toLine: 2,
text: "A *very fast* engine.",
});For contentEditable integrations, markengine also exposes:
parseInlineMarkdown(source)to render canonical inline HTMLapplyInlineFormatting(source, selection, command)to mutate inline content on the ASTreadInlineEditorDomState(root)to convert editor DOM back into canonical sourcegetInlineEditorSelectionSnapshot(root)/getInlineEditorSelectionOffsets(root)to read selection statesetInlineEditorSelectionOffsets(root, offsets)to restore the browser selectionnormalizeInlineLinkHref(href)to normalize user-entered inline link targets
The inline editor pipeline is intentionally split into small modules:
- inlineFormatting.ts applies selection-based format commands on the inline AST.
- inlineAst.ts owns AST splitting, normalization, serialization, and structural equality helpers.
- inlineEditorDom.ts converts
contentEditableDOM back into canonical inline source. - inlineEditorDomFormatting.ts isolates DOM formatting detection and canonical-element checks.
- inlineColorTokens.ts centralizes inline color normalization. UI-facing color presets live in the host app (they depend on the host's component kit), not in this engine.
- markdown/renderers/inlineStyleHelpers.ts shares inline style semantics across HTML, inline HTML, and React renderers.
This split keeps the editor responsibilities separate:
- DOM reading is independent from AST mutation.
- AST mutation is independent from rendering.
- Shared inline styling semantics live in one place instead of being repeated across renderers.
inlineFormatting.tscompares AST selections structurally instead of serializing them withJSON.stringify, avoiding extra allocations on repeated formatting operations.- Shared inline style helpers reduce duplicated per-render style construction logic across renderer implementations.
Large documents can move parse and render work off the main thread through a single MarkEngineWorker. The worker is intentionally not a pool: parsing one Markdown document is sequential, and editor integrations usually have one active document parse at a time.
import { createBrowserMarkEngineWorkerClient } from ".";
const engine = createBrowserMarkEngineWorkerClient(
new URL("./src/browser-worker.js", import.meta.url),
{ syncThresholdBytes: 8 * 1024 },
);
const parsed = await engine.parse(markdownSource);
const html = await engine.renderHtml(parsed.ast, {}, {
sourceByteLength: markdownSource.length,
});Node integrations can use createNodeMarkEngineWorkerClient() from the same public API. Sources above the threshold are encoded into transferable ArrayBuffers before posting to the worker. Documents below the threshold run synchronously because the worker round trip costs more than the parse.
This improves responsiveness, not throughput. A 1MB parse still costs CPU time, and end-to-end worker latency can include transfer plus structured-clone overhead; the win is that the main thread can keep handling input and paint. Run npm run bench:worker-blocking to compare main-thread timer delay for a 1MB parse on the main thread versus in MarkEngineWorker.
- src/block-parser.ts handles block structure.
- src/inline-parser.ts handles inline syntax.
- src/renderer.ts converts the AST to HTML.
- src/incremental.ts applies patches and reports changed nodes.
For a deeper architecture overview, see docs/Markdown_engine.md.
- Add the typed AST shape in src/types.ts. Extend
BlockKind, add a node interface with a precisespan, and include that interface in theBlockNodeunion. - Teach src/block-parser.ts to recognize the block before the paragraph fallback. Keep detection local and deterministic: parse only from
cursor, return the parsed node,nextLine, and diagnostics, then letparseMarkdownadd it to the block index. - Preserve incremental parsing by making the new node span cover every source line it owns. src/incremental.ts relies on those spans to choose the smallest safe reparse window.
- Render the new node in src/renderer.ts and, when source-view output needs special markup, in src/source-renderer.ts.
- Add tests that cover detection, rendering, malformed input, and incremental edits around the new block. The CI gate requires at least 95% branch coverage for src/block-parser.ts and src/inline-parser.ts, so include both positive and fallback cases.
- Run
npm run check,npm run coverage:parsers, andnpm run bench:cibefore opening a PR. The quality gate fails on explicitany, unused internal exports, circular imports, missing public API examples, and parser coverage drops.