diff --git a/.changeset/code-block-start-line.md b/.changeset/code-block-start-line.md new file mode 100644 index 00000000..a6874232 --- /dev/null +++ b/.changeset/code-block-start-line.md @@ -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 `` element. diff --git a/.changeset/custom-icons.md b/.changeset/custom-icons.md new file mode 100644 index 00000000..accef57b --- /dev/null +++ b/.changeset/custom-icons.md @@ -0,0 +1,17 @@ +--- +"streamdown": minor +--- + +Add support for customizing icons via the `icons` prop on ``. + +Users can override any subset of the built-in icons (copy, download, zoom, etc.) by passing a `Partial`: + +```tsx +import { Streamdown, type IconMap } from "streamdown"; + + + {content} + +``` + +Unspecified icons fall back to defaults. diff --git a/packages/streamdown/__tests__/code-block-start-line.test.tsx b/packages/streamdown/__tests__/code-block-start-line.test.tsx new file mode 100644 index 00000000..f3f7f141 --- /dev/null +++ b/packages/streamdown/__tests__/code-block-start-line.test.tsx @@ -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 }; + }) => { + 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 }; + }) => { + 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 } }) => { + node.data = node.data ?? {}; + node.data.hProperties = { existing: "value" }; + } + ); + + processor.runSync(tree); + + let props: Record | undefined; + visit( + tree, + "code", + (node: { data?: { hProperties?: Record } }) => { + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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) => ( + + {ui} + + ); + + it("renders without startLine using default counter (starts at 1)", async () => { + const { container } = render( + wrapWithContext() + ); + + 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( + + ) + ); + + 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( + + ) + ); + + 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( + + ) + ); + + await waitFor( + () => { + const code = container.querySelector("code"); + expect(code).toBeTruthy(); + expect(code?.style.counterReset).toBe("line 49"); + }, + { timeout: 5000 } + ); + }); +}); diff --git a/packages/streamdown/__tests__/icon-context.test.tsx b/packages/streamdown/__tests__/icon-context.test.tsx new file mode 100644 index 00000000..7cc9092c --- /dev/null +++ b/packages/streamdown/__tests__/icon-context.test.tsx @@ -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) => ( + + + +); + +const IconConsumer = ({ iconName }: { iconName: keyof typeof defaultIcons }) => { + const icons = useIcons(); + const Icon = icons[iconName]; + return ; +}; + +describe("IconProvider", () => { + it("provides default icons when no overrides are given", () => { + const { container } = render( + + + + ); + + const svg = container.querySelector("[data-testid='rendered-icon']"); + expect(svg).toBeTruthy(); + // Default CheckIcon has a child, not + expect(svg?.querySelector("path")).toBeTruthy(); + expect(svg?.querySelector("circle")).toBeFalsy(); + }); + + it("overrides a specific icon when provided", () => { + const { container } = render( + + + + ); + + const svg = container.querySelector("svg"); + expect(svg).toBeTruthy(); + // Custom icon renders a , not a + expect(svg?.querySelector("circle")).toBeTruthy(); + expect(svg?.querySelector("path")).toBeFalsy(); + }); + + it("keeps non-overridden icons as defaults", () => { + const { container } = render( + + + + ); + + const svg = container.querySelector("[data-testid='rendered-icon']"); + expect(svg).toBeTruthy(); + // CopyIcon should still be the default (has a ) + expect(svg?.querySelector("path")).toBeTruthy(); + }); + + it("falls back to defaults when icons prop is undefined", () => { + const { container } = render( + + + + ); + + 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(); + + const svg = container.querySelector("[data-testid='rendered-icon']"); + expect(svg).toBeTruthy(); + expect(svg?.querySelector("path")).toBeTruthy(); + }); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index 12aed87f..c6725672 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -25,6 +25,7 @@ import { } from "./lib/animate"; import { BlockIncompleteContext } from "./lib/block-incomplete-context"; import { components as defaultComponents } from "./lib/components"; +import { type IconMap, IconProvider } from "./lib/icon-context"; import { hasIncompleteCodeFence, hasTable } from "./lib/incomplete-code-utils"; import { Markdown, type Options } from "./lib/markdown"; import { parseMarkdownIntoBlocks } from "./lib/parse-blocks"; @@ -32,6 +33,7 @@ import { PluginContext } from "./lib/plugin-context"; import type { PluginConfig, ThemeInput } from "./lib/plugin-types"; import { PrefixContext } from "./lib/prefix-context"; import { preprocessCustomTags } from "./lib/preprocess-custom-tags"; +import { remarkCodeMeta } from "./lib/remark/code-meta"; import { createCn } from "./lib/utils"; export type { BundledLanguage, BundledTheme } from "shiki"; @@ -39,6 +41,7 @@ export type { AnimateOptions } from "./lib/animate"; // biome-ignore lint/performance/noBarrelFile: "required" export { createAnimatePlugin } from "./lib/animate"; export { useIsCodeFenceIncomplete } from "./lib/block-incomplete-context"; +export type { IconMap } from "./lib/icon-context"; export type { AllowElement, Components, @@ -164,6 +167,8 @@ export type StreamdownProps = Options & { linkSafety?: LinkSafetyConfig; /** Custom tags to allow through sanitization with their permitted attributes */ allowedTags?: AllowedTags; + /** Custom icons to override the default icons used in controls */ + icons?: Partial; /** Tailwind CSS prefix to prepend to all utility classes (e.g. `"tw"` produces `tw:flex` instead of `flex`). Enables Tailwind v4's `prefix()` support. Note: user-supplied `className` values are also prefixed. */ prefix?: string; /** Called when isAnimating transitions from false to true. Suppressed in mode="static". */ @@ -178,6 +183,10 @@ const defaultSanitizeSchema = { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "tel"], }, + attributes: { + ...defaultSchema.attributes, + code: [...(defaultSchema.attributes?.code ?? []), "metastring"], + }, }; export const defaultRehypePlugins: Record = { @@ -197,6 +206,7 @@ export const defaultRehypePlugins: Record = { export const defaultRemarkPlugins: Record = { gfm: [remarkGfm, {}], + codeMeta: remarkCodeMeta, } as const; // Stable plugin arrays for cache efficiency - created once at module level @@ -361,6 +371,7 @@ export const Streamdown = memo( enabled: true, }, allowedTags, + icons: iconOverrides, prefix, onAnimationStart, onAnimationEnd, @@ -607,23 +618,25 @@ export const Streamdown = memo( return ( - -
- + +
- {processedChildren} - -
-
+ + {processedChildren} + +
+
+
); @@ -633,44 +646,46 @@ export const Streamdown = memo( return ( - -
- {blocksToRender.length === 0 && caret && isAnimating && } - {blocksToRender.map((block, index) => { - const isLastBlock = index === blocksToRender.length - 1; - const isIncomplete = - isAnimating && isLastBlock && hasIncompleteCodeFence(block); - return ( - - ); - })} -
-
+ + +
+ {blocksToRender.length === 0 && caret && isAnimating && } + {blocksToRender.map((block, index) => { + const isLastBlock = index === blocksToRender.length - 1; + const isIncomplete = + isAnimating && isLastBlock && hasIncompleteCodeFence(block); + return ( + + ); + })} +
+
+
); diff --git a/packages/streamdown/lib/code-block/body.tsx b/packages/streamdown/lib/code-block/body.tsx index 0575adb2..9bb163d3 100644 --- a/packages/streamdown/lib/code-block/body.tsx +++ b/packages/streamdown/lib/code-block/body.tsx @@ -6,6 +6,7 @@ import { cn as baseCn } from "../utils"; type CodeBlockBodyProps = ComponentProps<"div"> & { result: HighlightResult; language: string; + startLine?: number; }; // Base line numbers class string (merged without prefix for memoization) @@ -43,7 +44,14 @@ const parseRootStyle = (rootStyle: string): Record => { }; export const CodeBlockBody = memo( - ({ children, result, language, className, ...rest }: CodeBlockBodyProps) => { + ({ + children, + result, + language, + className, + startLine, + ...rest + }: CodeBlockBodyProps) => { const cn = useCn(); // Prefix the pre-computed line number classes @@ -90,6 +98,11 @@ export const CodeBlockBody = memo( > 1 + ? { counterReset: `line ${startLine - 1}` } + : undefined + } > {result.tokens.map((row, index) => ( - {children ?? } + {children ?? } ); }; diff --git a/packages/streamdown/lib/code-block/highlighted-body.tsx b/packages/streamdown/lib/code-block/highlighted-body.tsx index e405c36b..9e303ecc 100644 --- a/packages/streamdown/lib/code-block/highlighted-body.tsx +++ b/packages/streamdown/lib/code-block/highlighted-body.tsx @@ -9,6 +9,7 @@ type HighlightedCodeBlockBodyProps = HTMLAttributes & { code: string; language: string; raw: HighlightResult; + startLine?: number; }; export const HighlightedCodeBlockBody = ({ @@ -16,6 +17,7 @@ export const HighlightedCodeBlockBody = ({ language, raw, className, + startLine, ...rest }: HighlightedCodeBlockBodyProps) => { const { shikiTheme } = useContext(StreamdownContext); @@ -49,6 +51,7 @@ export const HighlightedCodeBlockBody = ({ className={className} language={language} result={result} + startLine={startLine} {...rest} /> ); diff --git a/packages/streamdown/lib/code-block/index.tsx b/packages/streamdown/lib/code-block/index.tsx index 6b4c32ee..e19469cb 100644 --- a/packages/streamdown/lib/code-block/index.tsx +++ b/packages/streamdown/lib/code-block/index.tsx @@ -13,6 +13,8 @@ type CodeBlockProps = HTMLAttributes & { language: string; /** Whether the code block is still being streamed (incomplete) */ isIncomplete?: boolean; + /** Custom starting line number for line numbering (default: 1) */ + startLine?: number; }; const HighlightedCodeBlockBody = lazy(() => @@ -27,6 +29,7 @@ export const CodeBlock = ({ className, children, isIncomplete = false, + startLine, ...rest }: CodeBlockProps) => { const cn = useCn(); @@ -80,6 +83,7 @@ export const CodeBlock = ({ className={className} language={language} result={raw} + startLine={startLine} {...rest} /> } @@ -89,6 +93,7 @@ export const CodeBlock = ({ code={trimmedCode} language={language} raw={raw} + startLine={startLine} {...rest} /> diff --git a/packages/streamdown/lib/code-block/skeleton.tsx b/packages/streamdown/lib/code-block/skeleton.tsx index 6a721d66..d40ec58a 100644 --- a/packages/streamdown/lib/code-block/skeleton.tsx +++ b/packages/streamdown/lib/code-block/skeleton.tsx @@ -1,7 +1,8 @@ -import { Loader2Icon } from "../icons"; +import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; export const CodeBlockSkeleton = () => { + const { Loader2Icon } = useIcons(); const cn = useCn(); return (
import("./mermaid").then((mod) => ({ default: mod.Mermaid })) @@ -46,7 +48,7 @@ interface MarkdownPosition { } interface MarkdownNode { position?: MarkdownPosition; - properties?: { className?: string }; + properties?: { className?: string; metastring?: string }; } type WithNode = T & { @@ -776,6 +778,17 @@ const CodeComponent = ({ const match = className?.match(LANGUAGE_REGEX); const language = match?.at(1) ?? ""; + // Parse startLine from the code fence meta string (e.g. ```js startLine=10) + const metastring = node?.properties?.metastring; + const startLineMatch = metastring?.match(START_LINE_PATTERN); + const parsedStartLine = startLineMatch + ? Number.parseInt(startLineMatch[1], 10) + : undefined; + const startLine = + parsedStartLine !== undefined && parsedStartLine >= 1 + ? parsedStartLine + : undefined; + // Extract code content from children safely let code = ""; if ( @@ -870,6 +883,7 @@ const CodeComponent = ({ code={code} isIncomplete={isBlockIncomplete} language={language} + startLine={startLine} > {showCodeControls ? ( <> diff --git a/packages/streamdown/lib/icon-context.tsx b/packages/streamdown/lib/icon-context.tsx new file mode 100644 index 00000000..ea970a23 --- /dev/null +++ b/packages/streamdown/lib/icon-context.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { createContext, type SVGProps, useContext, useRef } from "react"; +import { + CheckIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + Loader2Icon, + Maximize2Icon, + RotateCcwIcon, + XIcon, + ZoomInIcon, + ZoomOutIcon, +} from "./icons"; + +export type IconComponent = React.ComponentType< + SVGProps & { size?: number } +>; + +export interface IconMap { + CheckIcon: IconComponent; + CopyIcon: IconComponent; + DownloadIcon: IconComponent; + ExternalLinkIcon: IconComponent; + Loader2Icon: IconComponent; + Maximize2Icon: IconComponent; + RotateCcwIcon: IconComponent; + XIcon: IconComponent; + ZoomInIcon: IconComponent; + ZoomOutIcon: IconComponent; +} + +export const defaultIcons: IconMap = { + CheckIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + Loader2Icon, + Maximize2Icon, + RotateCcwIcon, + XIcon, + ZoomInIcon, + ZoomOutIcon, +}; + +export const IconContext = createContext(defaultIcons); + +const shallowEqual = ( + a?: Partial, + b?: Partial +): boolean => { + if (a === b) return true; + if (!a || !b) return a === b; + const keysA = Object.keys(a) as (keyof IconMap)[]; + const keysB = Object.keys(b) as (keyof IconMap)[]; + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => a[key] === b[key]); +}; + +export const IconProvider = ({ + icons, + children, +}: { + icons?: Partial; + children: React.ReactNode; +}) => { + const prevIconsRef = useRef(icons); + const prevValueRef = useRef( + icons ? { ...defaultIcons, ...icons } : defaultIcons + ); + + if (!shallowEqual(prevIconsRef.current, icons)) { + prevIconsRef.current = icons; + prevValueRef.current = icons + ? { ...defaultIcons, ...icons } + : defaultIcons; + } + + const value = prevValueRef.current; + + return {children}; +}; + +export const useIcons = () => useContext(IconContext); diff --git a/packages/streamdown/lib/image.tsx b/packages/streamdown/lib/image.tsx index b8e7fe97..d49a79c1 100644 --- a/packages/streamdown/lib/image.tsx +++ b/packages/streamdown/lib/image.tsx @@ -1,6 +1,6 @@ import type { DetailedHTMLProps, ImgHTMLAttributes } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { DownloadIcon } from "./icons"; +import { useIcons } from "./icon-context"; import type { ExtraProps } from "./markdown"; import { useCn } from "./prefix-context"; import { save } from "./utils"; @@ -22,6 +22,7 @@ export const ImageComponent = ({ onError: onErrorProp, ...props }: ImageComponentProps) => { + const { DownloadIcon } = useIcons(); const cn = useCn(); const imgRef = useRef(null); const [imageLoaded, setImageLoaded] = useState(false); diff --git a/packages/streamdown/lib/link-modal.tsx b/packages/streamdown/lib/link-modal.tsx index 5bddf211..ffaaabc6 100644 --- a/packages/streamdown/lib/link-modal.tsx +++ b/packages/streamdown/lib/link-modal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { CheckIcon, CopyIcon, ExternalLinkIcon, XIcon } from "./icons"; +import { useIcons } from "./icon-context"; import { useCn } from "./prefix-context"; let activeModalCount = 0; @@ -31,6 +31,7 @@ export const LinkSafetyModal = ({ onClose, onConfirm, }: LinkSafetyModalProps) => { + const { CheckIcon, CopyIcon, ExternalLinkIcon, XIcon } = useIcons(); const cn = useCn(); const [copied, setCopied] = useState(false); diff --git a/packages/streamdown/lib/mermaid/download-button.tsx b/packages/streamdown/lib/mermaid/download-button.tsx index 9ec30517..c8798d8b 100644 --- a/packages/streamdown/lib/mermaid/download-button.tsx +++ b/packages/streamdown/lib/mermaid/download-button.tsx @@ -1,7 +1,7 @@ import type { MermaidConfig } from "mermaid"; import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; -import { DownloadIcon } from "../icons"; +import { useIcons } from "../icon-context"; import { useMermaidPlugin } from "../plugin-context"; import { useCn } from "../prefix-context"; import { save } from "../utils"; @@ -28,6 +28,7 @@ export const MermaidDownloadDropdown = ({ const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const { isAnimating } = useContext(StreamdownContext); + const icons = useIcons(); const mermaidPlugin = useMermaidPlugin(); const downloadMermaid = async (format: "mmd" | "png" | "svg") => { @@ -113,7 +114,7 @@ export const MermaidDownloadDropdown = ({ title="Download diagram" type="button" > - {children ?? } + {children ?? } {isOpen ? (
{ + const { Maximize2Icon, XIcon } = useIcons(); const cn = useCn(); const [isFullscreen, setIsFullscreen] = useState(false); const { isAnimating, controls: controlsConfig } = diff --git a/packages/streamdown/lib/mermaid/pan-zoom.tsx b/packages/streamdown/lib/mermaid/pan-zoom.tsx index 3e82c52a..c618f77b 100644 --- a/packages/streamdown/lib/mermaid/pan-zoom.tsx +++ b/packages/streamdown/lib/mermaid/pan-zoom.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "../icons"; +import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; interface PanZoomProps { @@ -24,6 +24,7 @@ export const PanZoom = ({ initialZoom = 1, fullscreen = false, }: PanZoomProps) => { + const { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } = useIcons(); const cn = useCn(); const containerRef = useRef(null); const contentRef = useRef(null); diff --git a/packages/streamdown/lib/remark/code-meta.ts b/packages/streamdown/lib/remark/code-meta.ts new file mode 100644 index 00000000..96a8d7f9 --- /dev/null +++ b/packages/streamdown/lib/remark/code-meta.ts @@ -0,0 +1,21 @@ +import type { Code, Root } from "mdast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; + +/** + * Remark plugin that forwards code fence meta strings to hast properties. + * This makes the meta string available as a `metastring` prop to the custom + * code component, enabling features like custom starting line numbers + * (e.g. `startLine=10`). + */ +export const remarkCodeMeta: Plugin<[], Root> = () => (tree) => { + visit(tree, "code", (node: Code) => { + if (node.meta) { + node.data = node.data ?? {}; + node.data.hProperties = { + ...((node.data.hProperties as Record) ?? {}), + metastring: node.meta, + }; + } + }); +}; diff --git a/packages/streamdown/lib/table/copy-dropdown.tsx b/packages/streamdown/lib/table/copy-dropdown.tsx index 55f0b001..bb54bd61 100644 --- a/packages/streamdown/lib/table/copy-dropdown.tsx +++ b/packages/streamdown/lib/table/copy-dropdown.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; -import { CheckIcon, CopyIcon } from "../icons"; +import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { extractTableDataFromElement, @@ -92,7 +92,8 @@ export const TableCopyDropdown = ({ }; }, []); - const Icon = isCopied ? CheckIcon : CopyIcon; + const icons = useIcons(); + const Icon = isCopied ? icons.CheckIcon : icons.CopyIcon; return (
diff --git a/packages/streamdown/lib/table/download-dropdown.tsx b/packages/streamdown/lib/table/download-dropdown.tsx index 54fb743b..710d1433 100644 --- a/packages/streamdown/lib/table/download-dropdown.tsx +++ b/packages/streamdown/lib/table/download-dropdown.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect, useRef, useState } from "react"; import { StreamdownContext } from "../../index"; -import { DownloadIcon } from "../icons"; +import { useIcons } from "../icon-context"; import { useCn } from "../prefix-context"; import { save } from "../utils"; import { @@ -28,6 +28,7 @@ export const TableDownloadButton = ({ }: TableDownloadButtonProps) => { const cn = useCn(); const { isAnimating } = useContext(StreamdownContext); + const icons = useIcons(); const downloadTableData = (event: React.MouseEvent) => { try { @@ -92,7 +93,7 @@ export const TableDownloadButton = ({ title={`Download table as ${format.toUpperCase()}`} type="button" > - {children ?? } + {children ?? } ); }; @@ -114,6 +115,7 @@ export const TableDownloadDropdown = ({ const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const { isAnimating } = useContext(StreamdownContext); + const icons = useIcons(); const downloadTableData = (format: "csv" | "markdown") => { try { @@ -172,7 +174,7 @@ export const TableDownloadDropdown = ({ title="Download table" type="button" > - {children ?? } + {children ?? } {isOpen ? (