Skip to content
Closed
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.
250 changes: 250 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,250 @@
import { render, waitFor } from "@testing-library/react";
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", async () => {
const { unified } = await import("unified");
const { default: remarkParse } = await import("remark-parse");
const { visit } = await import("unist-util-visit");

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", async () => {
const { unified } = await import("unified");
const { default: remarkParse } = await import("remark-parse");
const { visit } = await import("unist-util-visit");

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", async () => {
const { unified } = await import("unified");
const { default: remarkParse } = await import("remark-parse");
const { visit } = await import("unist-util-visit");

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 }
);
});
});
6 changes: 6 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { parseMarkdownIntoBlocks } from "./lib/parse-blocks";
import { PluginContext } from "./lib/plugin-context";
import type { PluginConfig } from "./lib/plugin-types";
import { preprocessCustomTags } from "./lib/preprocess-custom-tags";
import { remarkCodeMeta } from "./lib/remark/code-meta";
import { cn } from "./lib/utils";

export type { BundledLanguage, BundledTheme } from "shiki";
Expand Down Expand Up @@ -147,6 +148,10 @@ const defaultSanitizeSchema = {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "tel"],
},
attributes: {
...defaultSchema.attributes,
code: [...(defaultSchema.attributes?.code ?? []), "metastring"],
},
};

export const defaultRehypePlugins: Record<string, Pluggable> = {
Expand All @@ -166,6 +171,7 @@ export const defaultRehypePlugins: Record<string, Pluggable> = {

export const defaultRemarkPlugins: Record<string, Pluggable> = {
gfm: [remarkGfm, {}],
codeMeta: remarkCodeMeta,
} as const;

// Stable plugin arrays for cache efficiency - created once at module level
Expand Down
22 changes: 19 additions & 3 deletions packages/streamdown/lib/code-block/body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { cn } from "../utils";
type CodeBlockBodyProps = ComponentProps<"div"> & {
result: HighlightResult;
language: string;
startLine?: number;
};

// Memoize line numbers class string since it's constant
Expand Down Expand Up @@ -42,7 +43,14 @@ const parseRootStyle = (rootStyle: string): Record<string, string> => {
};

export const CodeBlockBody = memo(
({ children, result, language, className, ...rest }: CodeBlockBodyProps) => {
({
children,
result,
language,
className,
startLine,
...rest
}: CodeBlockBodyProps) => {
// Use CSS custom properties instead of direct inline styles so that
// dark-mode Tailwind classes can override without !important.
// This is necessary because !important syntax differs between Tailwind v3 and v4.
Expand Down Expand Up @@ -82,7 +90,14 @@ export const CodeBlockBody = memo(
)}
style={preStyle}
>
<code className="[counter-increment:line_0] [counter-reset:line]">
<code
className="[counter-increment:line_0] [counter-reset:line]"
style={
startLine && startLine > 1
? { counterReset: `line ${startLine - 1}` }
: undefined
}
>
{result.tokens.map((row, index) => (
<span
className={LINE_NUMBER_CLASSES}
Expand Down Expand Up @@ -150,7 +165,8 @@ export const CodeBlockBody = memo(
return (
prevProps.result === nextProps.result &&
prevProps.language === nextProps.language &&
prevProps.className === nextProps.className
prevProps.className === nextProps.className &&
prevProps.startLine === nextProps.startLine
);
}
);
3 changes: 3 additions & 0 deletions packages/streamdown/lib/code-block/highlighted-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ type HighlightedCodeBlockBodyProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: string;
raw: HighlightResult;
startLine?: number;
};

export const HighlightedCodeBlockBody = ({
code,
language,
raw,
className,
startLine,
...rest
}: HighlightedCodeBlockBodyProps) => {
const { shikiTheme } = useContext(StreamdownContext);
Expand Down Expand Up @@ -49,6 +51,7 @@ export const HighlightedCodeBlockBody = ({
className={className}
language={language}
result={result}
startLine={startLine}
{...rest}
/>
);
Expand Down
Loading