Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/portable-markdown-integrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@tanstack/markdown': patch
---

Forward raw code-fence metadata to `pre[data-meta]` and highlighter options in HTML, React, and Octane.

Add portable `InlineComponentNode` output for custom inline extensions, using the existing component maps without requiring raw HTML or a bundled math engine.

Add `urlTransform(url, kind, defaultUrl)` for application-controlled link and image policies. Default URL screening is unchanged; returning `null` removes a link or image while keeping its label content.

Use single-pass attribute escaping, expand renderer and security regression coverage, and update documentation and shipped skills. The combined renderer increase is 74-78 gzip bytes, with no new runtime dependencies.

Code fences with metadata now include an additional escaped HTML attribute. Consumers with exhaustive `InlineNode` switches should handle the new `inlineComponent` variant.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@

A tiny, fast, deterministic Markdown parser and renderer for blogs and documentation.

- 4.9 KB gzip parser
- 6.7 KB gzip HTML renderer
- 6.6 KB gzip React adapter
- 6.6 KB gzip Octane adapter
- 5.0 KB gzip parser
- 6.8 KB gzip HTML renderer
- 6.7 KB gzip React adapter
- 6.7 KB gzip Octane adapter
- zero runtime dependencies
- serializable AST
- safe defaults for raw HTML and executable URLs
Expand Down
10 changes: 5 additions & 5 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ These repository benchmarks bundle representative browser entry points from pinn

| Entry | Gzip | Brotli |
| --- | ---: | ---: |
| `@tanstack/markdown/parser` | 4.9 KB | 4.6 KB |
| `@tanstack/markdown/html` | 6.7 KB | 6.2 KB |
| `@tanstack/markdown/react` | 6.6 KB | 6.1 KB |
| React with streaming extension | 6.8 KB | 6.3 KB |
| `@tanstack/markdown/octane` | 6.6 KB | 6.1 KB |
| `@tanstack/markdown/parser` | 5.0 KB | 4.6 KB |
| `@tanstack/markdown/html` | 6.8 KB | 6.2 KB |
| `@tanstack/markdown/react` | 6.7 KB | 6.2 KB |
| React with streaming extension | 6.9 KB | 6.4 KB |
| `@tanstack/markdown/octane` | 6.7 KB | 6.2 KB |
| Marked | 12.5 KB | 11.5 KB |
| micromark | 15.4 KB | 13.7 KB |
| markdown-wasm JS + WASM | 31.3 KB | 26.4 KB |
Expand Down
26 changes: 26 additions & 0 deletions docs/core-concepts/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ By default:

Relative URLs, fragments, HTTP, HTTPS, email, and telephone links remain available. Other explicit protocols, including `data:`, are removed.

## Custom URL policy

`urlTransform(url, kind, defaultUrl)` runs while parsing Markdown link and image destinations, including reference destinations. It receives the parsed URL before screening, `'link'` or `'image'`, and the result of the built-in URL policy. Return `defaultUrl` to keep that policy, a nonempty string to replace it, or `null` to remove the link or image while keeping its label content. An empty string keeps the existing empty-URL behavior: images have an empty `src`, nonempty link destinations lose their wrapper, and empty source destinations remain empty. Prefer `null` when rejecting a URL.

For example, an application can allow only images it has already validated:

```ts
import { renderHtml } from '@tanstack/markdown'
import type { UrlTransform } from '@tanstack/markdown'

function imagePolicy(approvedImages: ReadonlySet<string>): UrlTransform {
return (url, kind, defaultUrl) =>
kind === 'image' && approvedImages.has(url) ? url : defaultUrl
}

const html = renderHtml('![Logo](data:image/png;base64,...)', {
urlTransform: imagePolicy(new Set()),
})
```

Populate the set only after validating image content, MIME type, and payload size. Do not approve every `data:` URL or populate it from untrusted Markdown. Callback results are trusted and are not screened again, though renderers still escape attribute values. Returning `defaultUrl` for links preserves the built-in protocol restrictions.

The callback is synchronous and runs before inline transforms. It does not screen raw HTML, extension-created URLs, image-alt markup, or an AST supplied directly to a renderer. It also does not rewrite generated heading and footnote anchors. Keep callbacks deterministic and free of side effects; parsing a nested link can inspect a destination that does not become a rendered link. Enabling raw HTML is not an image-policy control.

## Raw HTML

`allowHtml: true` is an explicit trusted-content boundary:
Expand Down Expand Up @@ -47,6 +71,8 @@ An extension `renderHtml` hook also returns trusted HTML. React and Octane compo

Renderers trust document ASTs supplied directly by the application. URL screening happens during Markdown parsing, not when rendering an arbitrary link or image node. Do not accept untrusted JSON as a document AST without validating its structure and applying your URL and HTML policies.

An application-validated image node can contain a `data:` URL and render through HTML, React, or Octane without `allowHtml`. A custom parser can supply a validated `MarkdownDocument` directly. For Markdown strings, use `urlTransform` instead of trying to recover removed URLs in an inline transform.

## Resource limits

The core limits parser nesting and inline delimiter scans. These are not a limit on total input size, footnote count, or work performed by extensions. Bound untrusted document sizes in the application, and batch streaming updates instead of rerendering on every incoming character.
Expand Down
27 changes: 26 additions & 1 deletion docs/guides/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Nested parsing shares the parser depth budget and heading slugger.

`transformInline` receives built-in inline nodes after parsing. Return the replacement array. Keep transforms deterministic and avoid repeated full-array scans for every node.

The hook runs once per inline container. Recurse through inline `children` when your transform also needs to handle content inside emphasis or links. Code spans and image alt text are not separate inline containers.

## Document transformation

`transformDocument` runs after all blocks and footnotes are built. Return a new `MarkdownDocument`, mutate and return nothing, or leave the document unchanged. The built-in heading collector uses this phase.
Expand All @@ -52,7 +54,30 @@ Nested parsing shares the parser depth budget and heading slugger.

`renderHtml` runs before built-in HTML node rendering. Return a string to claim the node or `undefined` to continue with the standard renderer. The context can render nested block and inline nodes.

This hook is HTML-specific. A custom node intended for both outputs should use a `ComponentNode` and map its tag through React `components`, or maintain an explicit React rendering layer. Returned HTML is trusted and is not sanitized.
This hook is HTML-specific. Returned HTML is trusted and is not sanitized.

## Custom components

Use `ComponentNode` for block content and `InlineComponentNode` for inline content. Both carry a `name`, source `attributes`, rendered string `properties`, and an optional `tagName`. Inline components have inline `children`, so they can appear in paragraphs, headings, links, and table cells without adding block wrappers.

```ts
import type { InlineComponentNode } from '@tanstack/markdown'

const badge: InlineComponentNode = {
type: 'inlineComponent',
name: 'status',
tagName: 'md-status',
attributes: {},
properties: { 'data-state': 'ready' },
children: [{ type: 'text', value: 'Ready' }],
}
```

An inline transform can return this node alongside ordinary text. The HTML renderer emits `<md-status data-state="ready">Ready</md-status>`. React and Octane use the same tag by default; map `'md-status'` through their `components` option to replace it. An HTML `renderHtml` hook can replace the same node for non-framework output.

Without `tagName`, inline components fall back to `<span>` and block components to `<md-comment-component>`, with `data-component` and JSON `data-attributes`. Values and text children are escaped. Tag and property names come from trusted extension code, not untrusted Markdown. Choose phrasing tags and inline-compatible replacements for inline nodes.

These nodes are JSON-serializable and do not require `allowHtml`. Math parsing and rendering can use them in an opt-in extension, but neither a math parser nor a rendering engine is included in the core.

## Ordering

Expand Down
10 changes: 5 additions & 5 deletions docs/guides/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ The generated browser bundle report records:

| Entry | Gzip | Brotli |
| --- | ---: | ---: |
| parser | 4.9 KB | 4.6 KB |
| HTML renderer | 6.7 KB | 6.2 KB |
| React adapter | 6.6 KB | 6.1 KB |
| Octane adapter | 6.6 KB | 6.1 KB |
| React adapter with streaming extension | 6.8 KB | 6.3 KB |
| parser | 5.0 KB | 4.6 KB |
| HTML renderer | 6.8 KB | 6.2 KB |
| React adapter | 6.7 KB | 6.2 KB |
| Octane adapter | 6.7 KB | 6.2 KB |
| React adapter with streaming extension | 6.9 KB | 6.4 KB |
| Streaming extension | 0.3 KB | 0.3 KB |
| docs preset | 2.3 KB | 2.1 KB |
| callouts extension | 0.3 KB | 0.3 KB |
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/syntax-highlighting.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ const two = 2

The parser records metadata even when no highlighter is configured. This keeps content parsing independent from presentation and lets a build pipeline change highlighters without rebuilding the AST.

The complete fence metadata string is available as `CodeBlockNode.meta`, `options.meta` in the highlighter callback, and `data-meta` on the rendered `<pre>` in HTML, React, and Octane. Framework `pre` replacements can read `props['data-meta']`. The attribute and callback field are omitted when there is no metadata. Existing title, filename, framework, and highlighted-line fields remain available.

Metadata is source text, not executable configuration. Validate any values you use to select a custom renderer; never evaluate metadata as code.

## Keep it server-only when possible

For static blogs and docs, parse and highlight during a build or server render, then send the finished markup. The Markdown package does not force highlighting or registered language grammars into client bundles.
2 changes: 1 addition & 1 deletion docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ TanStack Markdown spends its complexity budget on that path. It deliberately doe

### Small entry points

Current minified browser bundles are 4.9 KB gzip for the parser, 6.7 KB for HTML rendering, and 6.6 KB for either UI adapter with its framework runtime externalized. The generated [bundle report](https://github.com/TanStack/markdown/blob/main/reports/sizes.md) is the source of truth.
Current minified browser bundles are 5.0 KB gzip for the parser, 6.8 KB for HTML rendering, and 6.7 KB for either UI adapter with its framework runtime externalized. The generated [bundle report](https://github.com/TanStack/markdown/blob/main/reports/sizes.md) is the source of truth.

### Parse once, render many

Expand Down
1 change: 1 addition & 0 deletions docs/reference/parser.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Normalizes and parses a complete Markdown source string into a deterministic `Ma
| Option | Default | Behavior |
| --- | --- | --- |
| `allowHtml` | `false` | Recognize raw block and inline HTML nodes |
| `urlTransform` | built-in policy | Override parsed link and image URLs; see [Custom URL policy](../core-concepts/security#custom-url-policy) |
| `frontmatter` | `true` | Extract a leading `---` frontmatter block |
| `headingIds` | `true` | Generate IDs, disable them, or provide an ID function |
| `extensions` | `[]` | Run custom parser and transform hooks in array order |
Expand Down
18 changes: 13 additions & 5 deletions docs/reference/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Union of `HeadingNode`, `ParagraphNode`, `CodeBlockNode`, `ListNode`, `Blockquot

### `InlineNode`

Union of `TextNode`, `CodeSpanNode`, `StrongNode`, `EmphasisNode`, `StrikeNode`, `FootnoteReferenceNode`, `LinkNode`, `ImageNode`, `BreakNode`, and `HtmlInlineNode`.
Union of `TextNode`, `CodeSpanNode`, `StrongNode`, `EmphasisNode`, `StrikeNode`, `FootnoteReferenceNode`, `LinkNode`, `ImageNode`, `BreakNode`, `HtmlInlineNode`, and `InlineComponentNode`.

## Block nodes

Expand Down Expand Up @@ -110,11 +110,11 @@ Contains normalized footnote `id`, display `number`, and optional `referenceInde

### `LinkNode`

Contains sanitized `href`, optional `title`, and inline `children`.
Contains policy-processed `href`, optional `title`, and inline `children`.

### `ImageNode`

Contains sanitized `src`, text `alt`, and optional `title`.
Contains policy-processed `src`, text `alt`, and optional `title`.

### `BreakNode`

Expand All @@ -124,11 +124,19 @@ Marker node with `type: 'break'`.

Contains raw inline HTML `value`. It is created only when HTML parsing is enabled.

### `InlineComponentNode`

Contains `type: 'inlineComponent'`, `name`, source `attributes`, inline `children`, and optional rendered `tagName` and string `properties`. Uses the same component replacements as `ComponentNode`, with a `<span>` fallback when no tag is provided. See [Custom components](../guides/extensions#custom-components).

## Parsing and rendering options

### `ParseOptions`

Configures `allowHtml`, `frontmatter`, `headingIds`, and `extensions`. It also exposes `references`, `footnotes`, `footnoteOrder`, and `footnoteCounts` state used by nested parser contexts.
Configures `allowHtml`, `urlTransform`, `frontmatter`, `headingIds`, and `extensions`. It also exposes `references`, `footnotes`, `footnoteOrder`, and `footnoteCounts` state used by nested parser contexts.

### `UrlTransform`

Synchronous callback `(url: string, kind: 'link' | 'image', defaultUrl: string) => string | null`. Return the default screened URL, a trusted replacement, or `null` to keep only the label content. Applies during Markdown parsing, not to raw HTML or supplied ASTs. See [Custom URL policy](../core-concepts/security#custom-url-policy).

### `RenderOptions`

Expand All @@ -140,7 +148,7 @@ Synchronous callback `(code, lang?, options?) => string`. The returned string is

### `CodeHighlightOptions`

Contains optional `highlightLines` and `lineNumbers` passed to a `CodeHighlighter`.
Contains optional raw fence `meta`, `highlightLines`, and `lineNumbers` passed to a `CodeHighlighter`.

### `HeadingAnchorOptions`

Expand Down
Loading
Loading