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
4 changes: 2 additions & 2 deletions packages/components/src/components/code-block/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down
8 changes: 6 additions & 2 deletions packages/components/src/components/code-group/code-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -220,7 +220,11 @@ const CodeGroup = ({
{feedbackButton && feedbackButton}
<CopyToClipboardButton
codeBlockTheme={codeBlockTheme}
textToCopy={getNodeText(childArr[selectedIndex]?.props?.children)}
textToCopy={getCodeString(
childArr[selectedIndex]?.props?.children,
childArr[selectedIndex]?.props?.className,
true
)}
{...copyButtonProps}
/>
{askAiButton && askAiButton}
Expand Down
137 changes: 137 additions & 0 deletions packages/components/src/utils/dedent.ts
Original file line number Diff line number Diff line change
@@ -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.
//
// <CodeBlock>
// {`import {
// a,
// } from 'pkg';`}
// </CodeBlock>
//
// 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: () => (
// <CodeBlock filename="example.ts" language="ts">
// {`import {
// a,
// b,
// } from 'pkg';
//
// async function main() {
// console.log('hello');
// }`}
// </CodeBlock>
// ),
// };
//
// // Leading-newline env vars (no closing bracket).
// export const MDXStringChildrenLeadingNewline: Story = {
// render: () => (
// <CodeBlock language="bash">
// {`
// CLICKHOUSE_USERNAME=main
// CLICKHOUSE_FRAGMENT=
// CLICKHOUSE_IP=123.456.78.90
// `}
// </CodeBlock>
// ),
// };
//
// // Leading-newline yaml — relative nesting is preserved.
// export const MDXStringChildrenYaml: Story = {
// render: () => (
// <CodeBlock filename="app.yaml" language="yaml">
// {`
// applications:
// myapp:
// source:
// root: "/"
// `}
// </CodeBlock>
// ),
// };
//
// // Deeply nested — the full indentation ladder is preserved.
// export const MDXStringChildrenDeeplyNested: Story = {
// render: () => (
// <CodeBlock filename="nested.ts" language="ts">
// {`function outer() {
// function middle() {
// function inner() {
// return 42;
// }
// return inner();
// }
// return middle();
// }`}
// </CodeBlock>
// ),
// };
//
// // 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: () => (
// <CodeBlock filename="app.yaml" language="yaml">
// {`applications:
// myapp:
// source:
// root: "/"`}
// </CodeBlock>
// ),
// };
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");
}
3 changes: 2 additions & 1 deletion packages/components/src/utils/shiki/lib.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -50,7 +51,7 @@ function getCodeString(

const codeString = getNodeText(children);

return codeString;
return dedent(codeString);
}

function calculateCodeLinesFromHtml(html: string | undefined): number {
Expand Down
Loading