GitHub

Markdown

The continuous, virtualized Markdown viewer - one document-wide unified pipeline parses Markdown once, then visible HAST slices project into React.

The Markdown Viewer is the File Viewer's Markdown renderer. It turns a URL, Blob, or inline Markdown source into one continuous reading surface: Markdown semantics are resolved once for the full document, then the viewer projects only the visible HAST chunks into React.

It is not a thin wrapper around react-markdown, and it is not the Text Viewer's Pretext line layout. The text viewer family uses @chenglou/pretext for source-line layout; the Markdown Viewer uses unified/remark/rehype for Markdown semantics, then reuses shared text-viewer chrome, resource bounds, scroll interaction helpers, and inverse-sticky virtualization math.

Installation

pnpm dlx shadcn@latest add @retab/markdown-viewer

Usage

import { MarkdownViewer } from "@/components/ui/markdown-viewer";
 
export function Example() {
  return (
    <MarkdownViewer
      source={{
        kind: "text",
        text: "# Notes\n\nMarkdown should read in one continuous flow.",
        fileName: "notes.md",
        mimeType: "text/markdown",
      }}
    />
  );
}

URL and Blob sources use the same bounded text resource path as the Text Viewer:

<MarkdownViewer
  source={{
    kind: "url",
    url: "/samples/release-notes.md",
    fileName: "release-notes.md",
    mimeType: "text/markdown",
  }}
/>

Architecture Report

The runtime path is intentionally split into narrow stages:

StageOwnerOutput
Source loadingPlainTextViewerFrame and text resourcesBounded UTF-8 text plus download metadata
Markdown semanticsmarkdown-unified-pipeline.tsFull-document MDAST, HAST, messages, map
Document modelmarkdown-greenfield-document.tsBlocks, chunks, headings, fragment targets
Layoutmarkdown-greenfield-layout.tsEstimated/measured chunk frames
Projectionmarkdown-greenfield-virtualizer.tsVisible frame IDs and scroll anchors
Renderingmarkdown-greenfield-renderer.tsxReact elements for the visible HAST slices
Diagramsmarkdown-greenfield-diagram.tsxLazy, bounded SVG diagram surfaces

That separation is what lets the viewer feel like a normal document while behaving like a virtualized renderer. Markdown is parsed once, document-wide features resolve before slicing, and the DOM only contains the chunk window near the viewport.

Runtime Flow

MarkdownViewer is a small public wrapper. It delegates to PlainTextViewerFrame, which resolves URL, Blob, and inline text sources, applies byte/line bounds, and then mounts MarkdownGreenfieldContent.

MarkdownGreenfieldContent owns the reader state:

  • It reads the bounded text resource and asks useMarkdownGreenfieldDocument for the parsed document.
  • Small documents parse synchronously through the shared document cache.
  • Large documents parse asynchronously once they cross the worker threshold, using markdown-greenfield-document.worker.ts when available and falling back to an idle main-thread task when the worker cannot be used.
  • While parsing is pending, the viewer mounts a cheap pending document so the chrome and scroll surface stay stable.

The result is a MarkdownGreenfieldDocument, not a React tree. React rendering is deliberately deferred until the virtualizer knows which chunks are near the viewport.

Unified Pipeline

The Markdown semantic pipeline lives in one place: markdown-unified-pipeline.ts.

It builds a cached unified processor with:

  • remark-parse
  • remark-directive
  • remark-gfm
  • remark-breaks
  • remark-math
  • remark-gemoji
  • Retab prose transforms, GitHub alerts, definition lists, restricted components, code metadata, and trusted image metadata
  • remark-rehype
  • rehype-raw
  • rehype-slug
  • rehype-sanitize
  • Retab safe-input and trusted-metadata passes
  • cached KaTeX rendering

The important rule is that Markdown semantics finish before virtualization. Reference links, footnotes, heading slugs, raw HTML sanitization, component metadata, GFM tables, task lists, math, and alerts are all resolved against the whole document. Virtualization never parses a partial Markdown slice.

The pipeline also creates a source map. Later stages use it to attach source line and offset metadata to rendered blocks, to scroll highlight ranges into view, and to map native find hits back to real source lines.

Document Model

markdown-greenfield-document.ts converts the full HAST root into a stable document model:

  • blocks are semantic top-level units such as headings, paragraphs, lists, tables, code, math, frontmatter, raw HTML, images, diagrams, components, and footnotes.
  • Every block records a stable ID, source range, source text, source line lengths, generated/source-backed status, and a hostile-content flag.
  • chunks group adjacent blocks into bounded render units. The target is about 42 source lines, with a hard split around 64 source lines, and hostile blocks are isolated into their own chunks.
  • headings and fragmentTargets come from the same full-document slug model that produced rendered heading IDs.
  • The document is frozen and cached by text key, so repeated mounts can reuse the same parsed model.

Chunks are an internal implementation detail. They are not pages, and the user never sees page labels, page gutters, or page frames.

Layout

markdown-greenfield-layout.ts turns chunks into a vertical frame list. The first pass is deterministic and cheap:

  • Paragraph-like blocks estimate visual lines from source line lengths, available width, font scale, and tuned character widths.
  • Code blocks use mono line-height estimates.
  • Tables, images, math, components, diagrams, frontmatter, and hostile blocks have bounded type-specific estimates.
  • Very large tables reserve a virtualized-table height rather than estimating every row into the document flow.

Once real chunks mount, ChunkFrame reports measured heights with ResizeObserver. Those measurements are keyed by document, chunk, width, zoom, and layout policy version, then folded back into the layout frame on the next animation frame.

The measured-height loop is conservative: it ignores sub-pixel noise, batches updates, and only compensates scroll position for height changes entirely above the live viewport. That is what prevents newly measured rich content from pulling the reader back toward the top.

Virtualization

The viewer uses a custom chunk virtualizer rather than page virtualization. markdown-greenfield-virtualizer.ts does binary searches over the chunk frame list to find the pixel-overscanned visible range.

The mounted DOM has three main pieces:

  • a before spacer
  • one sticky render window
  • an after spacer

The sticky window uses the shared inverse-sticky math from text-viewer-virtualization.ts. The scroll container keeps the full document height, while the expensive React subtree stays small and sticky inside the viewport. As the reader scrolls, the virtualizer swaps the projected chunk IDs inside that sticky window.

The projection stores frame IDs as well as numeric indices. If measurements change while the user is scrolling, the viewer can keep rendering the same semantic window instead of blinking through an index-only mismatch.

Scroll Anchors

Scroll is source-aware and chunk-aware:

  • scrollToLineRange normalizes 1-based inclusive line ranges, finds the owning chunk, estimates the line offset inside that chunk, and scrolls the real viewport.
  • Hash navigation resolves a fragment to the document-owned target, then jumps through the same source-line scroll path.
  • Width changes, zoom changes, and other intentional reflows capture a chunk anchor before layout changes and restore the relative offset inside that chunk afterward.
  • Measurement-driven reflows apply only the height delta for measured chunks entirely above the viewport.

The browser's native scroll anchoring is disabled for the Markdown viewport so it does not fight the component's own anchor model.

Rendering

markdown-greenfield-renderer.tsx renders only the visible chunks. Each chunk passes its HAST children through hast-util-to-jsx-runtime with a component map that owns the polished Markdown UI.

The renderer is responsible for:

  • safe links with internal, relative, email, root, fragment, and external-link behavior
  • safe image and video surfaces with loading, ready, failed, and blocked states
  • GitHub alert blockquotes
  • directive callouts
  • restricted component markdown
  • frontmatter
  • footnotes
  • GFM task lists
  • raw HTML tags allowed by the sanitizer
  • math surfaces
  • code blocks
  • tables
  • diagrams

Rendered chunk output is cached by chunk identity, search state, active match, and fragment-ID policy. That keeps small scrolls from rebuilding JSX for chunks that remain in the visible window.

Code And Tables

Long code and table blocks have their own inner virtualization, because a single Markdown block can be much larger than the document chunk size.

Code blocks render as labelled source regions with normalized language labels, copy actions, titles, captions, line numbers, highlighted lines, and diff line styling. Once a code block crosses the line threshold, it mounts only the visible source lines inside a fixed-height source canvas.

Tables render GFM alignment, scoped headers, row/column metadata, inline cell Markdown, keyboard-scrollable horizontal overflow, and TSV copy. Large tables switch to a fixed-height virtualized body with row windows, so a large table does not force React to mount every cell in the document.

Mermaid Diagrams

Mermaid fenced blocks and restricted Diagram components render through MarkdownGreenfieldDiagram.

The diagram path is intentionally lazy and bounded:

  • source size and line-count limits reject oversized diagrams before rendering
  • an IntersectionObserver waits until the diagram is near the viewport
  • a reserved height keeps the Markdown layout stable while rendering
  • a per-instance external store prevents cross-diagram notification fan-out
  • source and SVG copy buttons live in the same labelled figure surface

The renderer prefers local mmdr WASM (/vendor/mmdr/typst_mmdr.wasm) for fast Mermaid-to-SVG rendering. If mmdr cannot load or cannot render a source, the pipeline falls back to Mermaid JS. Recoverable Mermaid layout failures can fall back again to simple sanitized SVG summaries for known diagram kinds.

Every SVG path is sanitized before mounting. Active SVG tags, embedded resources, SVG links, animation, style surfaces, and clobbering IDs are removed or rewritten before the HTML reaches the DOM.

Search And Native Find

Controls search is source-backed: matches are computed against the full raw Markdown source, not only against mounted React chunks. Previous/next navigation wraps through the result set and scrolls through the same source-line addressing path used by highlight.

Browser-native find gets an additional bridge. The viewer builds inert offscreen text records for chunks in idle batches. When the browser reveals a virtual hit through beforematch, the bridge scrolls the real Markdown viewport to the owning source range so the rendered chunk can mount.

Safety Model

The viewer treats Markdown as user content:

  • raw HTML is parsed with rehype-raw but narrowed through rehype-sanitize
  • user-authored id and name attributes are clobber-prefixed
  • raw user HTML cannot retain internal component metadata
  • unsafe links degrade to text
  • unsafe images and media render blocked states
  • SVG, SVGZ, data, and unsafe schemes are blocked for Markdown media
  • arbitrary MDX, import/export statements, spread props, expression props, event handlers, namespaced components, and remote components do not execute

Unsupported syntax becomes inert source-like output or a diagnostic fallback, never executable DOM.

Performance Model

The fast path works because expensive work is bounded:

  • Markdown is parsed once per document text key, not per visible chunk.
  • Large documents can parse outside the initial render path.
  • Layout starts with cheap block-aware estimates.
  • Real measurements are batched and cached.
  • The virtual window mounts only pixel-overscanned chunks.
  • The inverse-sticky window preserves one continuous scroll height without keeping all chunks in the DOM.
  • Expensive subtrees such as large code blocks and tables virtualize internally.
  • Mermaid diagrams render lazily near the viewport and prefer local mmdr WASM before Mermaid JS.
  • Hostile chunks render bounded source previews instead of full rich subtrees.
  • File Viewer can prewarm the Markdown renderer on idle when routing predicts a Markdown document.

The result is a continuous document that behaves like normal Markdown to the reader, but has the rendering cost profile of a small, measured viewport.

Pretext Boundary

The Markdown Viewer does not directly import or execute @chenglou/pretext.

Pretext matters to the broader viewer system because the Text Viewer and line-oriented text/code paths use it for wrapped source-line preparation, layout, and materialization. Markdown borrows the successful architecture shape - prepare a stable model, lay it out cheaply, materialize only the visible projection, and correct layout with anchors - but it applies that shape to a unified/HAST Markdown tree instead of to Pretext line records.

The shared pieces are viewer chrome, bounded text resource loading, source-line range types, scroll interaction helpers, zoom clamping, and the inverse-sticky virtual window helper.

Design

One continuous text/document layout, with virtualization as an internal implementation detail - no page count, page frame, or page delimiter is ever part of the Markdown user experience. Markdown is parsed once with unified (GFM, math, directives, raw HTML handling, and sanitization) before visible chunks are projected, so document-wide semantics such as reference links and footnotes resolve before the viewer slices the HAST.

Contract

  • Continuous rendering - no page count, page frame, or page delimiter should be part of the Markdown user experience.
  • Document-wide Markdown semantics - Markdown is parsed once with unified, GFM, math, directives, raw HTML handling, and sanitization before visible chunks are projected.
  • Block-aware layout - visible chunks start from deterministic block-aware height estimates, then measured heights feed back into the virtual frame.
  • Always rendered - Markdown is always shown as rendered output; there is no source/text view mode.
  • Markdown-first routing - sources are parsed as Markdown even when the file extension is generic text.
  • Source-line addressing - highlight and scrollToLineRange keep using 1-based inclusive source-line ranges.
  • Stable heading anchors - heading DOM IDs and virtual fragment navigation come from the same model-owned slug registry.
  • Reduced motion - automatic viewer scrolls honor prefers-reduced-motion while explicit behavior options are preserved.
  • Restricted component markdown - whitelisted components and component directives render through a schema-owned registry with quoted string props, enum validation, and Markdown children only where the component explicitly allows them.
  • Safe output - unsupported Markdown should degrade to inert text or code output rather than executable DOM.

Capabilities

  • A document-wide unified pipeline parses Markdown with remark-parse, remark-gfm, directives, math, gemoji, prose transforms, smart punctuation, remark-rehype, rehype-raw, rehype-sanitize, slugging, and KaTeX. Virtualization slices the resulting HAST after document semantics such as reference links and footnotes have already resolved.
  • Virtual chunks are projected as continuous document content, without visible page shells, page labels, or inter-page gutters. Blank Markdown renders an explicit empty document state.
  • The controls include source-backed search. Matches are computed against the full raw Markdown source, previous/next navigation wraps through the result set, the active match is highlighted distinctly, and it drives the same source-line scrolling and rendered highlight path used by external highlights.
  • Zoom scales the rendered type and spacing together off a single em cascade so the document grows as one system; the layout and spacing track the zoom level.
  • The greenfield document model keeps stable virtual chunk IDs, source-line ranges, measured heights, hostility flags, document headings, and a unified source map for line/offset lookup. The private virtualizer owns pixel-overscanned chunk projection, source-line scroll offset lookup, rendered chunk highlight markers, and scroll-anchor capture/restore across measured height updates.
  • GitHub blockquote alert markers such as > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], and > [!CAUTION] render as labelled alert surfaces with marker-free prose. Regular blockquotes keep semantic blockquote markup.
  • Directive callouts and restricted component syntax render through generated internal metadata that raw user HTML cannot spoof. The restricted component registry supports Metric, Badge, Callout, Accordion, Tabs / Tab, Image, Video, and Mermaid Diagram components. Unknown or invalid component syntax renders inert fallback content instead of executing arbitrary MDX.
  • YAML and TOML frontmatter at the start of the document renders as a first-class frontmatter block with raw source plus a conservative metadata summary for scalar values, simple lists, inline arrays, and TOML section keys.
  • Heading IDs, local #fragment links, direct hash loads, hash changes, and browser back/forward fragment navigation use the same document-owned slug and virtual scroll model. Browser-native find can match offscreen virtual chunks through an inert beforematch bridge that scrolls the real Markdown viewport to the owning source range.
  • Links and images use a dedicated safe URL policy. Unsafe links degrade to text, unsafe images render blocked media states, and Markdown images reject unsafe schemes plus SVG/SVGZ resources. Absolute HTTP(S) links open with noopener noreferrer, show an aria-hidden external-link cue, and expose stable link-kind/link-form markers; fragment, relative, root-relative, and email links stay in the current browsing context.
  • Safe raw HTML is parsed through rehype-raw, narrowed by rehype-sanitize, and rendered through styled React components for supported static tags such as details, summary, figure, figcaption, caption, dl, dt, dd, mark, kbd, q, ins, abbr, time, cite, dfn, small, var, samp, sub, and sup. User-authored raw HTML cannot retain internal component metadata, and raw id / name values are clobber-prefixed by the sanitizer while generated viewer IDs remain stable.
  • GFM autolink literals, bare email autolinks, strikethrough, soft line breaks, ordered-list starts, non-editable task-list checkboxes, nested lists, and reference links/images render through document-wide GFM semantics. Conservative definition-list shorthand renders as semantic dl / dt / dd markup.
  • Mermaid fenced blocks and restricted Diagram components prefer local mmdr WASM and fall back to Mermaid JS in a bounded diagram surface. Loading, ready, oversized, and failed states stay inside the same labelled keyboard-scrollable region, rendered SVG is sanitized before mounting, and source/SVG copy controls are available where applicable.
  • Inline and block math render through remark-math and cached KaTeX with untrusted input and bounded expansion.
  • GFM footnotes render with labelled references, labelled backrefs, bidirectional fragment targets, a labelled footnotes section, and document-wide definition resolution across virtual chunks.
  • Fenced code blocks render in labelled, horizontally scrollable source regions with normalized language labels, token styling, title/caption metadata, line numbers, highlighted lines, diff line styling, and copy controls. Mermaid fences are routed to the diagram surface.
  • GFM tables render with inline cell Markdown, alignment, a keyboard-focusable horizontal scroll region, row/column counts, scoped header cells, and a TSV copy action.
  • Images render through a safe Markdown image surface with blocked, loading, ready, and failed states, title captions associated through aria-describedby, lazy loading, max-width containment, decoded aspect-ratio stabilization, and SVG/data/blob resource blocking.
  • Oversized code, table, paragraph, list, and HTML payloads are flagged as hostile chunks, isolated from neighboring prose, given cheap deterministic layout estimates, and rendered as bounded source previews with inner line virtualization plus source copy instead of mounting the entire rich Markdown subtree.
  • URL, Blob, inline text, and MIME-only Markdown sources load through the bounded text resource layer. The File Viewer routes Markdown URL sources, Blob sources, inline text sources, and MIME-only Markdown sources through MarkdownViewer.

Feature Matrix

CapabilityMarkdown ViewerText Viewer Markdown mode
Document flowContinuous Markdown document flowContinuous text-oriented flow
Virtualization unitInternal Markdown chunks with no visible page modelSource/text rows and blocks
Markdown rendererDocument-wide unified/GFM pipeline with visible HAST projectionViewer-owned Markdown subset
Geometry strategyBlock-aware estimates plus measured chunk heightsPretext line/block estimates
TablesGFM tables with inline cell Markdown, alignment, horizontal scroll, scoped headers, row/column counts, row virtualization, and TSV copyBasic Markdown table path
Code blocksLabelled source blocks with normalized labels, token styling, title, caption, line numbers, highlights, diff styling, and copy controlsSource-like fallback path
FrontmatterFirst-class YAML/TOML blocks with raw source plus conservative scalar/list metadata summarySource text behavior
GitHub alerts> [!NOTE] style alert surfacesNot first-class
Component markdownRestricted component registry onlyNot first-class
Raw HTMLNarrow sanitized static HTML policyInert/source-oriented fallback
Fragment navigationModel-owned heading IDs drive DOM IDs and virtual scrollSource-line oriented
Mermaid diagramsLazy mmdr-first SVG rendering with Mermaid JS fallbackNot first-class

Unsupported Syntax

The Markdown Viewer intentionally does not execute arbitrary MDX. JSX-like component syntax is restricted to the whitelisted component registry, serializable string/number/boolean literal props, and safe Markdown children where the component allows them. Unknown components, non-literal expression props, spread props, event handlers, invalid enum values, remote or namespaced components, and unsafe directive props render as inert diagnostic fallbacks with source previews. Import/export statements render as inert source-like text.

Mermaid rendering prefers local mmdr WASM, then falls back to Mermaid JS with strict security settings, disabled flowchart HTML labels, bounded source size, and source-derived reserved height inside the viewer diagram surface. Recoverable layout/package failures fall back to a simple sanitized SVG summary for known diagram kinds. The SVG mount boundary denies active tags, embedded resources, SVG links, animation, and style surfaces, and prefixes clobbering SVG id / name values before mounting. Invalid diagrams or browser rendering failures stay contained in a non-crashing error/source surface.

Large hostile blocks are isolated and rendered through bounded source previews, so pathological code fences, tables, raw HTML, or nested AST payloads do not mount full rich subtrees. The preview surface keeps full source copyable while virtualizing its own source lines, so huge hostile payloads remain inspectable without mounting every line.

Choosing a Viewer

Use MarkdownViewer for Markdown files and inline Markdown sources where continuous document flow matters. Use TextViewer for prose text that is not Markdown, and use CodeViewer for logs, JSON, source code, and line-oriented files. File Viewer routing chooses the viewer by content semantics rather than by a generic text fallback.

API Reference

PropTypeDescription
sourceTextDocumentSourceRequired source descriptor. Supports URL, Blob, and inline text resources.
classNamestringOptional class on the viewer container.
controlsbooleanShow word count, zoom controls, and download action. Defaults to true.
highlightTextLineRange | null1-based inclusive source-line range to highlight.
barebooleanDrop the outer border/background so the viewer fills its container.
maxBytesnumberMaximum text payload size accepted by the viewer.
maxLinesnumberMaximum number of source lines accepted by the viewer.

Ref

import * as React from "react";
 
import {
  MarkdownViewer,
  type TextViewerHandle,
} from "@/components/ui/markdown-viewer";
 
export function Example() {
  const viewerRef = React.useRef<TextViewerHandle>(null);
 
  return (
    <MarkdownViewer
      ref={viewerRef}
      source={{ kind: "text", text: "# Alpha\n\n## Beta\n\nGamma" }}
      highlight={{ start: 3, end: 3 }}
    />
  );
}

scrollToLineRange(range, options?) scrolls any normalized source-line range into view, even when that source line is outside the currently mounted virtual window.

Source

markdown-viewer.tsx