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
15 changes: 15 additions & 0 deletions .changeset/code-block-start-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"streamdown": minor
---

Add support for custom starting line numbers in code blocks via the `startLine` meta option.

Code blocks can now specify a starting line number in the meta string:

````md
```js startLine=10
const x = 1;
```
````

This renders line numbers beginning at 10 instead of the default 1. The feature works by parsing the `startLine=N` value from the fenced-code meta string and applying `counter-reset: line N-1` to the `<code>` element.
17 changes: 17 additions & 0 deletions .changeset/custom-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"streamdown": minor
---

Add support for customizing icons via the `icons` prop on `<Streamdown>`.

Users can override any subset of the built-in icons (copy, download, zoom, etc.) by passing a `Partial<IconMap>`:

```tsx
import { Streamdown, type IconMap } from "streamdown";

<Streamdown icons={{ CheckIcon: MyCheckIcon }}>
{content}
</Streamdown>
```

Unspecified icons fall back to defaults.
241 changes: 241 additions & 0 deletions packages/streamdown/__tests__/code-block-start-line.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { render, waitFor } from "@testing-library/react";
import remarkParse from "remark-parse";
import { unified } from "unified";
import { visit } from "unist-util-visit";
import { describe, expect, it } from "vitest";
import { StreamdownContext } from "../index";
import { CodeBlock } from "../lib/code-block";
import { CodeBlockBody } from "../lib/code-block/body";
import { remarkCodeMeta } from "../lib/remark/code-meta";

// ---------------------------------------------------------------------------
// Unit tests for the remarkCodeMeta plugin
// ---------------------------------------------------------------------------

describe("remarkCodeMeta", () => {
it("exports a function", () => {
expect(typeof remarkCodeMeta).toBe("function");
});

it("attaches metastring to hProperties when meta is present", () => {
const processor = unified().use(remarkParse).use(remarkCodeMeta);

const markdown = "```js startLine=10\nconst x = 1;\n```";
const tree = processor.parse(markdown);
processor.runSync(tree);

let foundMeta: string | undefined;
visit(
tree,
"code",
(node: {
meta?: string;
data?: { hProperties?: Record<string, unknown> };
}) => {
foundMeta = node.data?.hProperties?.metastring as string | undefined;
}
);

expect(foundMeta).toBe("startLine=10");
});

it("does not attach metastring when meta is absent", () => {
const processor = unified().use(remarkParse).use(remarkCodeMeta);

const markdown = "```js\nconst x = 1;\n```";
const tree = processor.parse(markdown);
processor.runSync(tree);

let foundMeta: string | undefined;
visit(
tree,
"code",
(node: {
meta?: string;
data?: { hProperties?: Record<string, unknown> };
}) => {
foundMeta = node.data?.hProperties?.metastring as string | undefined;
}
);

expect(foundMeta).toBeUndefined();
});

it("preserves existing hProperties when adding metastring", () => {
const processor = unified().use(remarkParse).use(remarkCodeMeta);

const markdown = "```js startLine=5\nconst y = 2;\n```";
const tree = processor.parse(markdown);

// Manually pre-set an existing hProperty to ensure we don't overwrite it
visit(
tree,
"code",
(node: { data?: { hProperties?: Record<string, unknown> } }) => {
node.data = node.data ?? {};
node.data.hProperties = { existing: "value" };
}
);

processor.runSync(tree);

let props: Record<string, unknown> | undefined;
visit(
tree,
"code",
(node: { data?: { hProperties?: Record<string, unknown> } }) => {
props = node.data?.hProperties;
}
);

expect(props?.metastring).toBe("startLine=5");
expect(props?.existing).toBe("value");
});
});

// ---------------------------------------------------------------------------
// Unit tests for CodeBlockBody with startLine
// ---------------------------------------------------------------------------

describe("CodeBlockBody with startLine", () => {
const baseResult = {
tokens: [[{ content: "const x = 1;" }], [{ content: "const y = 2;" }]],
bg: "transparent",
fg: "inherit",
};

it("does not set counter-reset inline style when startLine is undefined", () => {
const { container } = render(
<CodeBlockBody language="javascript" result={baseResult} />
);

const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code?.style.counterReset).toBeFalsy();
});

it("does not set counter-reset inline style when startLine is 1", () => {
const { container } = render(
<CodeBlockBody language="javascript" result={baseResult} startLine={1} />
);

const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code?.style.counterReset).toBeFalsy();
});

it("sets counter-reset to N-1 when startLine=10", () => {
const { container } = render(
<CodeBlockBody language="javascript" result={baseResult} startLine={10} />
);

const code = container.querySelector("code");
expect(code).toBeTruthy();
// counter-reset: line 9 (N - 1 so the first displayed number is N)
expect(code?.style.counterReset).toBe("line 9");
});

it("sets counter-reset to N-1 when startLine=100", () => {
const { container } = render(
<CodeBlockBody
language="javascript"
result={baseResult}
startLine={100}
/>
);

const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code?.style.counterReset).toBe("line 99");
});
});

// ---------------------------------------------------------------------------
// Integration tests for CodeBlock with startLine
// ---------------------------------------------------------------------------

describe("CodeBlock with startLine", () => {
const wrapWithContext = (ui: React.ReactNode) => (
<StreamdownContext.Provider
value={{
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
mode: "streaming" as const,
}}
>
{ui}
</StreamdownContext.Provider>
);

it("renders without startLine using default counter (starts at 1)", async () => {
const { container } = render(
wrapWithContext(<CodeBlock code="line one\nline two" language="text" />)
);

await waitFor(
() => {
const code = container.querySelector("code");
expect(code).toBeTruthy();
// No inline counter-reset means lines start from 1 (CSS default)
expect(code?.style.counterReset).toBeFalsy();
},
{ timeout: 5000 }
);
});

it("renders with startLine=10 applying counter-reset: line 9", async () => {
const { container } = render(
wrapWithContext(
<CodeBlock code="const x = 1;" language="js" startLine={10} />
)
);

await waitFor(
() => {
const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code?.style.counterReset).toBe("line 9");
},
{ timeout: 5000 }
);
});

it("renders with startLine=1 without any counter-reset override", async () => {
const { container } = render(
wrapWithContext(
<CodeBlock code="const x = 1;" language="js" startLine={1} />
)
);

await waitFor(
() => {
const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code?.style.counterReset).toBeFalsy();
},
{ timeout: 5000 }
);
});

it("renders with startLine=50 applying counter-reset: line 49", async () => {
const { container } = render(
wrapWithContext(
<CodeBlock
code={"line A\nline B\nline C"}
language="text"
startLine={50}
/>
)
);

await waitFor(
() => {
const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code?.style.counterReset).toBe("line 49");
},
{ timeout: 5000 }
);
});
});
85 changes: 85 additions & 0 deletions packages/streamdown/__tests__/icon-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import type { SVGProps } from "react";
import { describe, expect, it } from "vitest";
import {
defaultIcons,
IconProvider,
useIcons,
} from "../lib/icon-context";

const CustomCheckIcon = (props: SVGProps<SVGSVGElement>) => (
<svg data-testid="custom-check" {...props}>
<circle r="5" />
</svg>
);

const IconConsumer = ({ iconName }: { iconName: keyof typeof defaultIcons }) => {
const icons = useIcons();
const Icon = icons[iconName];
return <Icon data-testid="rendered-icon" />;
};

describe("IconProvider", () => {
it("provides default icons when no overrides are given", () => {
const { container } = render(
<IconProvider>
<IconConsumer iconName="CheckIcon" />
</IconProvider>
);

const svg = container.querySelector("[data-testid='rendered-icon']");
expect(svg).toBeTruthy();
// Default CheckIcon has a <path> child, not <circle>
expect(svg?.querySelector("path")).toBeTruthy();
expect(svg?.querySelector("circle")).toBeFalsy();
});

it("overrides a specific icon when provided", () => {
const { container } = render(
<IconProvider icons={{ CheckIcon: CustomCheckIcon }}>
<IconConsumer iconName="CheckIcon" />
</IconProvider>
);

const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Custom icon renders a <circle>, not a <path>
expect(svg?.querySelector("circle")).toBeTruthy();
expect(svg?.querySelector("path")).toBeFalsy();
});

it("keeps non-overridden icons as defaults", () => {
const { container } = render(
<IconProvider icons={{ CheckIcon: CustomCheckIcon }}>
<IconConsumer iconName="CopyIcon" />
</IconProvider>
);

const svg = container.querySelector("[data-testid='rendered-icon']");
expect(svg).toBeTruthy();
// CopyIcon should still be the default (has a <path>)
expect(svg?.querySelector("path")).toBeTruthy();
});

it("falls back to defaults when icons prop is undefined", () => {
const { container } = render(
<IconProvider icons={undefined}>
<IconConsumer iconName="DownloadIcon" />
</IconProvider>
);

const svg = container.querySelector("[data-testid='rendered-icon']");
expect(svg).toBeTruthy();
expect(svg?.querySelector("path")).toBeTruthy();
});
});

describe("useIcons", () => {
it("returns default icons outside of a provider", () => {
const { container } = render(<IconConsumer iconName="CheckIcon" />);

const svg = container.querySelector("[data-testid='rendered-icon']");
expect(svg).toBeTruthy();
expect(svg?.querySelector("path")).toBeTruthy();
});
});
Loading
Loading