Skip to content

Commit 57cd3b5

Browse files
sleitorhaydenbleaselclaude
authored
feat: allow customizing icons via icons prop (#414)
* feat: support custom starting line number in code blocks Add support for specifying a custom starting line number via the code fence meta string, e.g.: ```js startLine=10 const x = 1; ``` Implementation: - Add `remarkCodeMeta` remark plugin that forwards the fenced-code meta string to hast as the `metastring` property so it is available to the custom React code component. - Update the rehype-sanitize default schema to allow the `metastring` attribute on `code` elements. - Parse `startLine=N` from the meta string in `CodeComponent` and pass the value down as a `startLine` prop. - In `CodeBlockBody`, apply `counter-reset: line N-1` as an inline style when `startLine > 1`, which overrides the Tailwind counter-reset class and makes CSS counters begin from the specified number. - Pass `startLine` through the `CodeBlock` and `HighlightedCodeBlockBody` call chains. - Add 12 tests covering the remark plugin, CodeBlockBody prop, and CodeBlock integration. Closes: resolves #287 * fix: lint errors — use literal keys, hoist regex, sort imports * feat: allow customizing icons via icons prop Add an IconContext-based system that lets users override any of the built-in icons (CheckIcon, CopyIcon, DownloadIcon, etc.) by passing a Partial<IconMap> via the new `icons` prop on <Streamdown>. - Created lib/icon-context.tsx with IconMap type, IconProvider, and useIcons hook - Updated all sub-components to consume icons via useIcons() instead of direct imports - Added icons prop to StreamdownProps with IconProvider wrapper - Exported IconMap type from package entry Closes #412 * style: fix biome lint and formatting errors * Merge branch 'main' into feat/custom-icons * fix: review fixes for icons and startLine features - Add missing changeset for icons feature (minor) - Fix IconComponent type to include size prop matching actual usage - Fix IconProvider memoization: use shallow comparison instead of referential equality so inline icon objects don't cause re-renders - Move START_LINE_PATTERN constant below all imports - Add bounds checking for startLine (must be >= 1) - Convert dynamic imports to top-level imports in startLine tests - Add icon-context tests (5 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Dmitrii Troitskii <jsleitor@gmail.com> Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 01d27e9 commit 57cd3b5

21 files changed

+599
-73
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"streamdown": minor
3+
---
4+
5+
Add support for custom starting line numbers in code blocks via the `startLine` meta option.
6+
7+
Code blocks can now specify a starting line number in the meta string:
8+
9+
````md
10+
```js startLine=10
11+
const x = 1;
12+
```
13+
````
14+
15+
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.

.changeset/custom-icons.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"streamdown": minor
3+
---
4+
5+
Add support for customizing icons via the `icons` prop on `<Streamdown>`.
6+
7+
Users can override any subset of the built-in icons (copy, download, zoom, etc.) by passing a `Partial<IconMap>`:
8+
9+
```tsx
10+
import { Streamdown, type IconMap } from "streamdown";
11+
12+
<Streamdown icons={{ CheckIcon: MyCheckIcon }}>
13+
{content}
14+
</Streamdown>
15+
```
16+
17+
Unspecified icons fall back to defaults.
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import remarkParse from "remark-parse";
3+
import { unified } from "unified";
4+
import { visit } from "unist-util-visit";
5+
import { describe, expect, it } from "vitest";
6+
import { StreamdownContext } from "../index";
7+
import { CodeBlock } from "../lib/code-block";
8+
import { CodeBlockBody } from "../lib/code-block/body";
9+
import { remarkCodeMeta } from "../lib/remark/code-meta";
10+
11+
// ---------------------------------------------------------------------------
12+
// Unit tests for the remarkCodeMeta plugin
13+
// ---------------------------------------------------------------------------
14+
15+
describe("remarkCodeMeta", () => {
16+
it("exports a function", () => {
17+
expect(typeof remarkCodeMeta).toBe("function");
18+
});
19+
20+
it("attaches metastring to hProperties when meta is present", () => {
21+
const processor = unified().use(remarkParse).use(remarkCodeMeta);
22+
23+
const markdown = "```js startLine=10\nconst x = 1;\n```";
24+
const tree = processor.parse(markdown);
25+
processor.runSync(tree);
26+
27+
let foundMeta: string | undefined;
28+
visit(
29+
tree,
30+
"code",
31+
(node: {
32+
meta?: string;
33+
data?: { hProperties?: Record<string, unknown> };
34+
}) => {
35+
foundMeta = node.data?.hProperties?.metastring as string | undefined;
36+
}
37+
);
38+
39+
expect(foundMeta).toBe("startLine=10");
40+
});
41+
42+
it("does not attach metastring when meta is absent", () => {
43+
const processor = unified().use(remarkParse).use(remarkCodeMeta);
44+
45+
const markdown = "```js\nconst x = 1;\n```";
46+
const tree = processor.parse(markdown);
47+
processor.runSync(tree);
48+
49+
let foundMeta: string | undefined;
50+
visit(
51+
tree,
52+
"code",
53+
(node: {
54+
meta?: string;
55+
data?: { hProperties?: Record<string, unknown> };
56+
}) => {
57+
foundMeta = node.data?.hProperties?.metastring as string | undefined;
58+
}
59+
);
60+
61+
expect(foundMeta).toBeUndefined();
62+
});
63+
64+
it("preserves existing hProperties when adding metastring", () => {
65+
const processor = unified().use(remarkParse).use(remarkCodeMeta);
66+
67+
const markdown = "```js startLine=5\nconst y = 2;\n```";
68+
const tree = processor.parse(markdown);
69+
70+
// Manually pre-set an existing hProperty to ensure we don't overwrite it
71+
visit(
72+
tree,
73+
"code",
74+
(node: { data?: { hProperties?: Record<string, unknown> } }) => {
75+
node.data = node.data ?? {};
76+
node.data.hProperties = { existing: "value" };
77+
}
78+
);
79+
80+
processor.runSync(tree);
81+
82+
let props: Record<string, unknown> | undefined;
83+
visit(
84+
tree,
85+
"code",
86+
(node: { data?: { hProperties?: Record<string, unknown> } }) => {
87+
props = node.data?.hProperties;
88+
}
89+
);
90+
91+
expect(props?.metastring).toBe("startLine=5");
92+
expect(props?.existing).toBe("value");
93+
});
94+
});
95+
96+
// ---------------------------------------------------------------------------
97+
// Unit tests for CodeBlockBody with startLine
98+
// ---------------------------------------------------------------------------
99+
100+
describe("CodeBlockBody with startLine", () => {
101+
const baseResult = {
102+
tokens: [[{ content: "const x = 1;" }], [{ content: "const y = 2;" }]],
103+
bg: "transparent",
104+
fg: "inherit",
105+
};
106+
107+
it("does not set counter-reset inline style when startLine is undefined", () => {
108+
const { container } = render(
109+
<CodeBlockBody language="javascript" result={baseResult} />
110+
);
111+
112+
const code = container.querySelector("code");
113+
expect(code).toBeTruthy();
114+
expect(code?.style.counterReset).toBeFalsy();
115+
});
116+
117+
it("does not set counter-reset inline style when startLine is 1", () => {
118+
const { container } = render(
119+
<CodeBlockBody language="javascript" result={baseResult} startLine={1} />
120+
);
121+
122+
const code = container.querySelector("code");
123+
expect(code).toBeTruthy();
124+
expect(code?.style.counterReset).toBeFalsy();
125+
});
126+
127+
it("sets counter-reset to N-1 when startLine=10", () => {
128+
const { container } = render(
129+
<CodeBlockBody language="javascript" result={baseResult} startLine={10} />
130+
);
131+
132+
const code = container.querySelector("code");
133+
expect(code).toBeTruthy();
134+
// counter-reset: line 9 (N - 1 so the first displayed number is N)
135+
expect(code?.style.counterReset).toBe("line 9");
136+
});
137+
138+
it("sets counter-reset to N-1 when startLine=100", () => {
139+
const { container } = render(
140+
<CodeBlockBody
141+
language="javascript"
142+
result={baseResult}
143+
startLine={100}
144+
/>
145+
);
146+
147+
const code = container.querySelector("code");
148+
expect(code).toBeTruthy();
149+
expect(code?.style.counterReset).toBe("line 99");
150+
});
151+
});
152+
153+
// ---------------------------------------------------------------------------
154+
// Integration tests for CodeBlock with startLine
155+
// ---------------------------------------------------------------------------
156+
157+
describe("CodeBlock with startLine", () => {
158+
const wrapWithContext = (ui: React.ReactNode) => (
159+
<StreamdownContext.Provider
160+
value={{
161+
shikiTheme: ["github-light", "github-dark"],
162+
controls: true,
163+
isAnimating: false,
164+
mode: "streaming" as const,
165+
}}
166+
>
167+
{ui}
168+
</StreamdownContext.Provider>
169+
);
170+
171+
it("renders without startLine using default counter (starts at 1)", async () => {
172+
const { container } = render(
173+
wrapWithContext(<CodeBlock code="line one\nline two" language="text" />)
174+
);
175+
176+
await waitFor(
177+
() => {
178+
const code = container.querySelector("code");
179+
expect(code).toBeTruthy();
180+
// No inline counter-reset means lines start from 1 (CSS default)
181+
expect(code?.style.counterReset).toBeFalsy();
182+
},
183+
{ timeout: 5000 }
184+
);
185+
});
186+
187+
it("renders with startLine=10 applying counter-reset: line 9", async () => {
188+
const { container } = render(
189+
wrapWithContext(
190+
<CodeBlock code="const x = 1;" language="js" startLine={10} />
191+
)
192+
);
193+
194+
await waitFor(
195+
() => {
196+
const code = container.querySelector("code");
197+
expect(code).toBeTruthy();
198+
expect(code?.style.counterReset).toBe("line 9");
199+
},
200+
{ timeout: 5000 }
201+
);
202+
});
203+
204+
it("renders with startLine=1 without any counter-reset override", async () => {
205+
const { container } = render(
206+
wrapWithContext(
207+
<CodeBlock code="const x = 1;" language="js" startLine={1} />
208+
)
209+
);
210+
211+
await waitFor(
212+
() => {
213+
const code = container.querySelector("code");
214+
expect(code).toBeTruthy();
215+
expect(code?.style.counterReset).toBeFalsy();
216+
},
217+
{ timeout: 5000 }
218+
);
219+
});
220+
221+
it("renders with startLine=50 applying counter-reset: line 49", async () => {
222+
const { container } = render(
223+
wrapWithContext(
224+
<CodeBlock
225+
code={"line A\nline B\nline C"}
226+
language="text"
227+
startLine={50}
228+
/>
229+
)
230+
);
231+
232+
await waitFor(
233+
() => {
234+
const code = container.querySelector("code");
235+
expect(code).toBeTruthy();
236+
expect(code?.style.counterReset).toBe("line 49");
237+
},
238+
{ timeout: 5000 }
239+
);
240+
});
241+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { render, screen } from "@testing-library/react";
2+
import type { SVGProps } from "react";
3+
import { describe, expect, it } from "vitest";
4+
import {
5+
defaultIcons,
6+
IconProvider,
7+
useIcons,
8+
} from "../lib/icon-context";
9+
10+
const CustomCheckIcon = (props: SVGProps<SVGSVGElement>) => (
11+
<svg data-testid="custom-check" {...props}>
12+
<circle r="5" />
13+
</svg>
14+
);
15+
16+
const IconConsumer = ({ iconName }: { iconName: keyof typeof defaultIcons }) => {
17+
const icons = useIcons();
18+
const Icon = icons[iconName];
19+
return <Icon data-testid="rendered-icon" />;
20+
};
21+
22+
describe("IconProvider", () => {
23+
it("provides default icons when no overrides are given", () => {
24+
const { container } = render(
25+
<IconProvider>
26+
<IconConsumer iconName="CheckIcon" />
27+
</IconProvider>
28+
);
29+
30+
const svg = container.querySelector("[data-testid='rendered-icon']");
31+
expect(svg).toBeTruthy();
32+
// Default CheckIcon has a <path> child, not <circle>
33+
expect(svg?.querySelector("path")).toBeTruthy();
34+
expect(svg?.querySelector("circle")).toBeFalsy();
35+
});
36+
37+
it("overrides a specific icon when provided", () => {
38+
const { container } = render(
39+
<IconProvider icons={{ CheckIcon: CustomCheckIcon }}>
40+
<IconConsumer iconName="CheckIcon" />
41+
</IconProvider>
42+
);
43+
44+
const svg = container.querySelector("svg");
45+
expect(svg).toBeTruthy();
46+
// Custom icon renders a <circle>, not a <path>
47+
expect(svg?.querySelector("circle")).toBeTruthy();
48+
expect(svg?.querySelector("path")).toBeFalsy();
49+
});
50+
51+
it("keeps non-overridden icons as defaults", () => {
52+
const { container } = render(
53+
<IconProvider icons={{ CheckIcon: CustomCheckIcon }}>
54+
<IconConsumer iconName="CopyIcon" />
55+
</IconProvider>
56+
);
57+
58+
const svg = container.querySelector("[data-testid='rendered-icon']");
59+
expect(svg).toBeTruthy();
60+
// CopyIcon should still be the default (has a <path>)
61+
expect(svg?.querySelector("path")).toBeTruthy();
62+
});
63+
64+
it("falls back to defaults when icons prop is undefined", () => {
65+
const { container } = render(
66+
<IconProvider icons={undefined}>
67+
<IconConsumer iconName="DownloadIcon" />
68+
</IconProvider>
69+
);
70+
71+
const svg = container.querySelector("[data-testid='rendered-icon']");
72+
expect(svg).toBeTruthy();
73+
expect(svg?.querySelector("path")).toBeTruthy();
74+
});
75+
});
76+
77+
describe("useIcons", () => {
78+
it("returns default icons outside of a provider", () => {
79+
const { container } = render(<IconConsumer iconName="CheckIcon" />);
80+
81+
const svg = container.querySelector("[data-testid='rendered-icon']");
82+
expect(svg).toBeTruthy();
83+
expect(svg?.querySelector("path")).toBeTruthy();
84+
});
85+
});

0 commit comments

Comments
 (0)