diff --git a/packages/components/src/components/code-block/code-block.tsx b/packages/components/src/components/code-block/code-block.tsx
index 61ac6772..047d1e0c 100644
--- a/packages/components/src/components/code-block/code-block.tsx
+++ b/packages/components/src/components/code-block/code-block.tsx
@@ -2,8 +2,8 @@ import type { ReactNode, RefObject } from "react";
import { Classes } from "@/constants/selectors";
import { cn } from "@/utils/cn";
-import { getNodeText } from "@/utils/get-node-text";
import type { CodeBlockTheme, CodeStyling } from "@/utils/shiki/code-styling";
+import { getCodeString } from "@/utils/shiki/lib";
import { BaseCodeBlock } from "./base-code-block";
import { CodeHeader } from "./code-header";
@@ -98,7 +98,7 @@ const CodeBlock = function CodeBlock(params: CodeBlockProps) {
copyButtonProps,
} = params;
- const codeString = getNodeText(children);
+ const codeString = getCodeString(children, className, true);
const hasGrayBackgroundContainer = !!filename || !!icon;
return (
diff --git a/packages/components/src/components/code-group/code-group.tsx b/packages/components/src/components/code-group/code-group.tsx
index 6f285ccd..c41ce8ad 100644
--- a/packages/components/src/components/code-group/code-group.tsx
+++ b/packages/components/src/components/code-group/code-group.tsx
@@ -18,8 +18,8 @@ import {
import { Icon as ComponentIcon } from "@/components/icon";
import { Classes } from "@/constants/selectors";
import { cn } from "@/utils/cn";
-import { getNodeText } from "@/utils/get-node-text";
import type { CodeBlockTheme, CodeStyling } from "@/utils/shiki/code-styling";
+import { getCodeString } from "@/utils/shiki/lib";
import { LanguageDropdown } from "./language-dropdown";
@@ -220,7 +220,11 @@ const CodeGroup = ({
{feedbackButton && feedbackButton}
{askAiButton && askAiButton}
diff --git a/packages/components/src/utils/dedent.ts b/packages/components/src/utils/dedent.ts
new file mode 100644
index 00000000..08d4d6d2
--- /dev/null
+++ b/packages/components/src/utils/dedent.ts
@@ -0,0 +1,137 @@
+// Strips the indentation that leaks in from surrounding JSX/MDX when code is
+// passed to a code block as a template literal, e.g.
+//
+//
+// {`import {
+// a,
+// } from 'pkg';`}
+//
+//
+// In a template literal the first line sits flush against the opening backtick
+// while every continuation line carries the JSX indentation. We therefore
+// compute the shared indent from the continuation lines only (ignoring blank
+// lines) and strip it from them, leaving the first line as-is. Leading and
+// trailing blank lines are trimmed.
+//
+// This is a no-op for correctly-formatted code, which always has at least one
+// continuation line back at the base column (a closing brace, a top-level
+// statement, etc.), making the shared continuation indent 0. Only blocks where
+// every continuation line is indented — the JSX-pollution signature — are
+// changed. Indentation is measured in spaces to avoid corrupting mixed
+// tab/space input.
+//
+// Limitation: when the first line is flush against the backtick AND is itself a
+// parent of indentation-significant content (e.g. a top-level yaml key whose
+// children never return to column 0), the intended first level of nesting is
+// indistinguishable from JSX pollution and gets stripped. For such content,
+// author with a leading newline so every line carries the shared indent.
+//
+// Stories for manual verification — paste any of these into
+// code-block.stories.tsx and view in Storybook. Each should render with clean,
+// author-intended indentation (except the documented limitation case).
+//
+// // Issue #226: flush first line, brace-terminated continuation lines.
+// export const MDXStringChildrenIndentation: Story = {
+// render: () => (
+//
+// {`import {
+// a,
+// b,
+// } from 'pkg';
+//
+// async function main() {
+// console.log('hello');
+// }`}
+//
+// ),
+// };
+//
+// // Leading-newline env vars (no closing bracket).
+// export const MDXStringChildrenLeadingNewline: Story = {
+// render: () => (
+//
+// {`
+// CLICKHOUSE_USERNAME=main
+// CLICKHOUSE_FRAGMENT=
+// CLICKHOUSE_IP=123.456.78.90
+// `}
+//
+// ),
+// };
+//
+// // Leading-newline yaml — relative nesting is preserved.
+// export const MDXStringChildrenYaml: Story = {
+// render: () => (
+//
+// {`
+// applications:
+// myapp:
+// source:
+// root: "/"
+// `}
+//
+// ),
+// };
+//
+// // Deeply nested — the full indentation ladder is preserved.
+// export const MDXStringChildrenDeeplyNested: Story = {
+// render: () => (
+//
+// {`function outer() {
+// function middle() {
+// function inner() {
+// return 42;
+// }
+// return inner();
+// }
+// return middle();
+// }`}
+//
+// ),
+// };
+//
+// // Limitation: a flush first line that is itself a parent of
+// // indentation-significant content loses its first nesting level (myapp ends
+// // up flush with applications). Author with a leading newline (see the yaml
+// // story above) to avoid this.
+// export const MDXStringChildrenFlushParentLimitation: Story = {
+// render: () => (
+//
+// {`applications:
+// myapp:
+// source:
+// root: "/"`}
+//
+// ),
+// };
+const LEADING_SPACES_REGEX = /^[ ]*/;
+
+export function dedent(code: string): string {
+ const lines = code.split("\n");
+ if (lines.length <= 1) {
+ return code;
+ }
+
+ const continuationIndents = lines
+ .slice(1)
+ .filter((line) => line.trim() !== "")
+ .map((line) => line.match(LEADING_SPACES_REGEX)?.[0].length ?? 0);
+
+ const minIndent = continuationIndents.length
+ ? Math.min(...continuationIndents)
+ : 0;
+
+ const dedented =
+ minIndent > 0
+ ? lines.map((line, i) => (i === 0 ? line : line.slice(minIndent)))
+ : lines.slice();
+
+ while (dedented.length && dedented[0].trim() === "") {
+ dedented.shift();
+ }
+ while (dedented.length && dedented.at(-1)?.trim() === "") {
+ dedented.pop();
+ }
+
+ return dedented.join("\n");
+}
diff --git a/packages/components/src/utils/shiki/lib.ts b/packages/components/src/utils/shiki/lib.ts
index 99c9bab0..1b5c57e6 100644
--- a/packages/components/src/utils/shiki/lib.ts
+++ b/packages/components/src/utils/shiki/lib.ts
@@ -1,5 +1,6 @@
import { type ReactNode, useMemo } from "react";
+import { dedent } from "@/utils/dedent";
import { getNodeText } from "@/utils/get-node-text";
import { SHIKI_CLASSNAME } from "@/utils/shiki/constants";
@@ -50,7 +51,7 @@ function getCodeString(
const codeString = getNodeText(children);
- return codeString;
+ return dedent(codeString);
}
function calculateCodeLinesFromHtml(html: string | undefined): number {