Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/add-ascii-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@streamdown/ascii": minor
---

Add `@streamdown/ascii`, a new plugin package that renders agent-generated ASCII and Unicode box-drawing diagrams (`┌─┐`, `│ │`, `└─┘`, `──►`) as stable preformatted blocks. It binds to ```ascii, ```diagram, and ```chart code fences by default, disables font ligatures so sequences like `-->` don't collapse into a stylized arrow glyph, uses an advance-consistent monospace font stack, and renders each block as a single unbroken text node with no per-line span wrappers so streaming appends never reflow existing rows. Plugs into the existing `plugins.renderers` extension point (`import { ascii } from "@streamdown/ascii"`) with no core changes required.
123 changes: 123 additions & 0 deletions apps/website/content/docs/plugins/ascii.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
title: "@streamdown/ascii"
description: Render ASCII and Unicode box-drawing diagrams without breaking alignment.
type: reference
summary: Preformatted rendering for agent-generated ASCII/box-drawing diagrams and flowcharts.
prerequisites:
- /docs/plugins
related:
- /docs/plugins/mermaid
- /docs/custom-renderers
---

The `@streamdown/ascii` plugin renders ASCII and Unicode box-drawing diagrams (`┌─┐`, `│ │`, `└─┘`, `──►`) as stable preformatted blocks, so they don't break in the browser the way they do with typical web typography.

- Binds to ```ascii, ```diagram, and ```chart code fences by default
- Disables font ligatures so sequences like `-->` are never collapsed into a stylized arrow glyph
- Renders a single, unbroken text node with no per-line span wrappers or syntax highlighting, so streaming appends never reflow existing rows
- Uses an advance-consistent monospace font stack so box-drawing characters line up with plain ASCII

## The problem

LLMs and coding agents default to ASCII and Unicode box-drawing characters for architecture diagrams and flowcharts instead of Mermaid. Rendered as regular Markdown code, these break in a few common ways:

- **Font ligatures** collapse ASCII sequences — `-->` renders as a single stylized arrow glyph, destroying column alignment
- **Line-by-line span wrappers** (common in syntax highlighters) disrupt vertical alignment between rows
- **Standard web monospace fonts** don't guarantee uniform advance widths for box-drawing characters (`─│┌┐└┘`) versus plain ASCII

`@streamdown/ascii` renders these fences as plain `<pre>` blocks with ligatures disabled and an advance-consistent font stack, so the diagram looks the same as it did when the model generated it.

## Install

```package-install
npm install @streamdown/ascii
```

## Usage

```tsx title="chat.tsx" lineNumbers
import { Streamdown } from "streamdown";
import { ascii } from "@streamdown/ascii";

<Streamdown plugins={{ renderers: [ascii] }}>
{markdown}
</Streamdown>
```

For advanced configuration, use `createAsciiPlugin`:

```tsx title="app/page.tsx"
import { Streamdown } from "streamdown";
import { createAsciiPlugin } from "@streamdown/ascii";

const ascii = createAsciiPlugin({
languages: ["ascii", "diagram", "chart", "box"],
className: "my-ascii-block",
fontFamily: "Menlo, Consolas, monospace",
});

export default function Page() {
return (
<Streamdown plugins={{ renderers: [ascii] }}>
{markdown}
</Streamdown>
);
}
```

Your AI can then output diagrams using any of the bound fences:

````markdown
```ascii
┌─────────┐ ┌─────────┐
│ Client │────►│ Server │
└─────────┘ └─────────┘
```
````

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `languages` | `string[]` | `["ascii", "diagram", "chart"]` | Code fence languages bound to the renderer |
| `className` | `string` | — | Extra class names applied to the rendered `<pre>` element |
| `fontFamily` | `string` | `ui-monospace, "SF Mono", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", Menlo, Consolas, monospace` | Override the monospace font stack |

## Streaming safety

Diagrams stream in one character at a time along with the rest of the response. `@streamdown/ascii` keeps this stable in two ways:

- The rendered `<pre>` always contains exactly one text child (`{code}`) — no per-line `<span>` wrappers, no syntax highlighting — so appending characters never causes existing rows to reflow.
- The component's tree shape does not change between an incomplete and a complete fence. Incompleteness is only ever surfaced through a stable `data-incomplete` attribute, never by adding, removing, or swapping elements, so React never remounts the block mid-stream.

## Styling

The rendered `<pre>` sets:

- `font-variant-ligatures: none` and `font-feature-settings: "liga" 0, "calt" 0` — prevents ASCII arrow sequences (`-->`, `<--`) from being collapsed into ligature glyphs
- `white-space: pre` — preserves whitespace exactly as generated, without wrapping (wrapping destroys ASCII art column alignment)
- `overflow-x: auto` — wide diagrams scroll horizontally instead of wrapping
- `tab-size: 2` — consistent tab rendering

## Plugin interface

The ASCII plugin implements Streamdown's `CustomRenderer` shape, so it plugs into the existing `plugins.renderers` extension point with no core changes required:

```tsx
interface AsciiPlugin {
component: React.ComponentType<AsciiRendererProps>;
language: string[];
}

interface AsciiRendererProps {
code: string;
isIncomplete: boolean;
language: string;
meta?: string;
}
```

## Related features

- [Custom renderers](/docs/custom-renderers) - The general-purpose extension point this plugin uses
- [Mermaid](/docs/plugins/mermaid) - Interactive diagram rendering for Mermaid syntax
2 changes: 1 addition & 1 deletion apps/website/content/docs/plugins/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Plugins",
"pages": ["index", "code", "mermaid", "math", "cjk"]
"pages": ["index", "code", "mermaid", "math", "cjk", "ascii"]
}
120 changes: 120 additions & 0 deletions packages/streamdown-ascii/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ascii, createAsciiPlugin } from "../index";

describe("ascii", () => {
describe("plugin properties", () => {
it("binds the default languages", () => {
expect(ascii.language).toEqual(["ascii", "diagram", "chart"]);
});

it("exposes a component", () => {
expect(typeof ascii.component).toBe("function");
});
});
});

describe("createAsciiPlugin", () => {
it("creates a plugin with default options", () => {
const plugin = createAsciiPlugin();
expect(plugin.language).toEqual(["ascii", "diagram", "chart"]);
expect(typeof plugin.component).toBe("function");
});

it("binds custom languages", () => {
const plugin = createAsciiPlugin({ languages: ["box-drawing"] });
expect(plugin.language).toEqual(["box-drawing"]);
});

it("creates independent plugin instances", () => {
const plugin1 = createAsciiPlugin({ languages: ["ascii"] });
const plugin2 = createAsciiPlugin({ languages: ["diagram"] });

expect(plugin1.language).toEqual(["ascii"]);
expect(plugin2.language).toEqual(["diagram"]);
});

describe("rendered output", () => {
const code = "┌─────┐\n│ box │\n└─────┘";

it("renders a single <pre> element containing exactly one text child", () => {
const plugin = createAsciiPlugin();
const Component = plugin.component;
const { container } = render(
<Component code={code} isIncomplete={false} language="ascii" />
);

const pre = container.querySelector("pre");
expect(pre).toBeTruthy();
expect(pre?.childNodes.length).toBe(1);
expect(pre?.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE);
expect(pre?.textContent).toBe(code);
});

it("applies ligature/white-space/overflow-safe styling", () => {
const plugin = createAsciiPlugin();
const Component = plugin.component;
const { container } = render(
<Component code={code} isIncomplete={false} language="ascii" />
);

const pre = container.querySelector("pre");
expect(pre?.style.whiteSpace).toBe("pre");
expect(pre?.style.overflowX).toBe("auto");
expect(pre?.style.fontVariantLigatures).toBe("none");
expect(pre?.style.fontFeatureSettings).toBe('"liga" 0, "calt" 0');
expect(pre?.style.fontFamily).toContain("ui-monospace");
});

it("keeps the tree shape stable across isIncomplete true -> false", () => {
const plugin = createAsciiPlugin();
const Component = plugin.component;
const { container, rerender } = render(
<Component code={code} isIncomplete={true} language="ascii" />
);

const preBefore = container.querySelector("pre");
expect(preBefore?.getAttribute("data-incomplete")).toBe("true");
expect(preBefore?.childNodes.length).toBe(1);

rerender(<Component code={code} isIncomplete={false} language="ascii" />);

const preAfter = container.querySelector("pre");
expect(preAfter).toBe(preBefore);
expect(preAfter?.getAttribute("data-incomplete")).toBe("false");
expect(preAfter?.childNodes.length).toBe(1);
});

it("does not throw when isIncomplete is true with partial content", () => {
const plugin = createAsciiPlugin();
const Component = plugin.component;
expect(() =>
render(
<Component code={"┌─────"} isIncomplete={true} language="ascii" />
)
).not.toThrow();
});

it("applies a custom className", () => {
const plugin = createAsciiPlugin({ className: "my-ascii-block" });
const Component = plugin.component;
const { container } = render(
<Component code={code} isIncomplete={false} language="ascii" />
);

const pre = container.querySelector("pre");
expect(pre?.className).toBe("my-ascii-block");
});

it("applies a custom fontFamily", () => {
const plugin = createAsciiPlugin({ fontFamily: "Menlo, monospace" });
const Component = plugin.component;
const { container } = render(
<Component code={code} isIncomplete={false} language="ascii" />
);

const pre = container.querySelector("pre");
expect(pre?.style.fontFamily).toBe("Menlo, monospace");
});
});
});
133 changes: 133 additions & 0 deletions packages/streamdown-ascii/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use client";

import type { ComponentType, CSSProperties } from "react";

/**
* Monospace stack chosen for uniform, box-drawing-safe character advances.
* Standard web monospace fonts (e.g. the system default) frequently give
* `┌─┐│└┘` characters a different advance width than plain ASCII, which
* breaks column alignment in agent-generated diagrams.
*/
const DEFAULT_FONT_FAMILY =
'ui-monospace, "SF Mono", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", Menlo, Consolas, monospace';

/**
* Code fence languages bound to the ASCII renderer by default.
*/
const DEFAULT_LANGUAGES = ["ascii", "diagram", "chart"];

/**
* Props passed to the ASCII renderer component.
*/
export interface AsciiRendererProps {
/**
* The raw text content inside the code fence
*/
code: string;
/**
* `true` while the code fence is still being streamed
*/
isIncomplete: boolean;
/**
* The language identifier from the code fence
*/
language: string;
/**
* Raw metastring from the code fence, if present
*/
meta?: string;
}

/**
* Options for creating an ASCII plugin
*/
export interface AsciiPluginOptions {
/**
* Extra class names applied to the rendered `<pre>` element
*/
className?: string;
/**
* Override the monospace font stack used for the rendered block
* @default 'ui-monospace, "SF Mono", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", Menlo, Consolas, monospace'
*/
fontFamily?: string;
/**
* Code fence languages bound to this renderer
* @default ["ascii", "diagram", "chart"]
*/
languages?: string[];
}

/**
* Plugin for rendering ASCII / Unicode box-drawing diagrams.
*
* Structurally compatible with Streamdown's `CustomRenderer` type, so it can
* be passed directly into `plugins.renderers` without a core dependency on
* this package.
*/
export interface AsciiPlugin {
/**
* The React component that renders matching code fences
*/
component: ComponentType<AsciiRendererProps>;
/**
* Code fence languages bound to this renderer
*/
language: string[];
}

const createAsciiRenderer = (
fontFamily: string,
className?: string
): ComponentType<AsciiRendererProps> => {
const AsciiRenderer = ({
code,
isIncomplete,
language,
}: AsciiRendererProps) => {
const style: CSSProperties = {
fontFamily,
fontFeatureSettings: '"liga" 0, "calt" 0',
fontVariantLigatures: "none",
overflowX: "auto",
tabSize: 2,
whiteSpace: "pre",
};

return (
<pre
className={className}
data-incomplete={isIncomplete}
data-language={language}
data-streamdown="ascii-block"
style={style}
>
{code}
</pre>
);
};

AsciiRenderer.displayName = "AsciiRenderer";

return AsciiRenderer;
};

/**
* Create an ASCII plugin with optional configuration
*/
export function createAsciiPlugin(
options: AsciiPluginOptions = {}
): AsciiPlugin {
const fontFamily = options.fontFamily ?? DEFAULT_FONT_FAMILY;
const languages = options.languages ?? DEFAULT_LANGUAGES;

return {
component: createAsciiRenderer(fontFamily, options.className),
language: languages,
};
}

/**
* Pre-configured ASCII plugin with default settings
*/
export const ascii = createAsciiPlugin();
Loading
Loading